From 08d69214447e99be641320eff4fcf6872ef2126d Mon Sep 17 00:00:00 2001 From: aitbc Date: Sat, 25 Apr 2026 06:34:59 +0200 Subject: [PATCH] feat: migrate coordinator-api routers and exchange_island CLI to use centralized aitbc package HTTP client - Replace httpx.Client with aitbc.AITBCHTTPClient in client.py get_blocks endpoint - Migrate monitoring_dashboard.py from httpx.AsyncClient to AITBCHTTPClient - Replace httpx with AITBCHTTPClient in blockchain.py get_balance function - Add NetworkError exception handling across all migrated endpoints - Remove async context managers in favor of direct AITBCHTTPClient usage - Remove httpx imports --- .../coordinator-api/src/app/routers/client.py | 33 ++-- .../src/app/routers/monitoring_dashboard.py | 31 ++-- .../src/app/services/blockchain.py | 15 +- .../src/app/services/secure_pickle.py | 12 +- cli/aitbc_cli/commands/exchange_island.py | 157 ++++++++++-------- 5 files changed, 135 insertions(+), 113 deletions(-) diff --git a/apps/coordinator-api/src/app/routers/client.py b/apps/coordinator-api/src/app/routers/client.py index 2d7fa337..3b8683cd 100755 --- a/apps/coordinator-api/src/app/routers/client.py +++ b/apps/coordinator-api/src/app/routers/client.py @@ -226,22 +226,27 @@ async def get_blocks( ) -> dict: # type: ignore[arg-type] """Get recent blockchain blocks""" try: - import httpx - # Query the local blockchain node for blocks - with httpx.Client() as client: - response = client.get( - "http://10.1.223.93:8082/rpc/blocks-range", params={"start": offset, "end": offset + limit}, timeout=5 + client = AITBCHTTPClient(timeout=5.0) + try: + blocks_data = client.get( + "http://10.1.223.93:8082/rpc/blocks-range", params={"start": offset, "end": offset + limit} ) - - if response.status_code == 200: - blocks_data = response.json() - return { - "blocks": blocks_data.get("blocks", []), - "total": blocks_data.get("total", 0), - "limit": limit, - "offset": offset, - } + return { + "blocks": blocks_data.get("blocks", []), + "total": blocks_data.get("total", 0), + "limit": limit, + "offset": offset, + } + except NetworkError as e: + logger.error(f"Failed to fetch blocks: {e}") + return { + "blocks": [], + "total": 0, + "limit": limit, + "offset": offset, + "error": "Failed to fetch blocks", + } else: # Fallback to empty response if blockchain node is unavailable return { diff --git a/apps/coordinator-api/src/app/routers/monitoring_dashboard.py b/apps/coordinator-api/src/app/routers/monitoring_dashboard.py index 301ccf90..9ad9ced9 100755 --- a/apps/coordinator-api/src/app/routers/monitoring_dashboard.py +++ b/apps/coordinator-api/src/app/routers/monitoring_dashboard.py @@ -191,24 +191,25 @@ async def collect_all_health_data() -> dict[str, Any]: """Collect health data from all enhanced services""" health_data = {} - async with httpx.AsyncClient(timeout=5.0) as client: - tasks = [] + client = AITBCHTTPClient(timeout=5.0) + tasks = [] - for service_id, service_info in SERVICES.items(): - task = check_service_health(client, service_id, service_info) - tasks.append(task) + for service_id, service_info in SERVICES.items(): + task = check_service_health(client, service_id, service_info) + tasks.append(task) - results = await asyncio.gather(*tasks, return_exceptions=True) + results = await asyncio.gather(*tasks, return_exceptions=True) - for i, (service_id, service_info) in enumerate(SERVICES.items()): - result = results[i] - if isinstance(result, Exception): - health_data[service_id] = { - "status": "unhealthy", - "error": str(result), - "timestamp": datetime.utcnow().isoformat(), - } - else: + for i, (service_id, service_info) in enumerate(SERVICES.items()): + result = results[i] + if isinstance(result, Exception): + health_data[service_id] = { + "status": "unhealthy", + "error": str(result), + "timestamp": datetime.utcnow().isoformat(), + } + else: + health_data[service_id] = result return health_data diff --git a/apps/coordinator-api/src/app/services/blockchain.py b/apps/coordinator-api/src/app/services/blockchain.py index a9578ef8..ba4e7144 100755 --- a/apps/coordinator-api/src/app/services/blockchain.py +++ b/apps/coordinator-api/src/app/services/blockchain.py @@ -53,17 +53,16 @@ def get_balance(address: str) -> float | None: return None try: - with httpx.Client() as client: + client = AITBCHTTPClient(timeout=10.0) + try: response = client.get( f"{BLOCKCHAIN_RPC}/getBalance/{address}", headers={"X-Api-Key": settings.admin_api_keys[0] if settings.admin_api_keys else ""}, ) - - if response.status_code == 200: - data = response.json() - return float(data.get("balance", 0)) - + return float(response.get("balance", 0)) + except NetworkError as e: + logger.error("Error getting balance: %s", e) + return None except Exception as e: logger.error("Error getting balance: %s", e) - - return None + return None diff --git a/apps/coordinator-api/src/app/services/secure_pickle.py b/apps/coordinator-api/src/app/services/secure_pickle.py index ee16c173..91dd7d68 100644 --- a/apps/coordinator-api/src/app/services/secure_pickle.py +++ b/apps/coordinator-api/src/app/services/secure_pickle.py @@ -8,6 +8,8 @@ import os import pickle from typing import Any +from aitbc import REPO_DIR + # Safe classes whitelist: builtins and common types SAFE_MODULES = { "builtins": { @@ -40,8 +42,8 @@ def _initialize_allowed_origins(): # 1. All site-packages directories that are under the application venv for entry in os.sys.path: if "site-packages" in entry and os.path.isdir(entry): - # Only include if it's inside /opt/aitbc/apps/coordinator-api/.venv or similar - if "/opt/aitbc" in entry: # restrict to our app directory + # Only include if it's inside the AITBC repository + if str(REPO_DIR) in entry: # restrict to our app directory _ALLOWED_ORIGINS.add(os.path.realpath(entry)) # 2. Standard library paths (typically without site-packages) # We'll allow any origin that resolves to a .py file outside site-packages and not in user dirs @@ -95,14 +97,14 @@ def _lock_sys_path(): if isinstance(sys.path, list): trusted = [] for p in sys.path: - # Keep site-packages under /opt/aitbc (our venv) - if "site-packages" in p and "/opt/aitbc" in p: + # Keep site-packages under REPO_DIR (our venv) + if "site-packages" in p and str(REPO_DIR) in p: trusted.append(p) # Keep stdlib paths (no site-packages, under /usr/lib/python) elif "site-packages" not in p and ("/usr/lib/python" in p or "/usr/local/lib/python" in p): trusted.append(p) # Keep our application directory - elif p.startswith("/opt/aitbc/apps/coordinator-api"): + elif p.startswith(str(REPO_DIR / "apps" / "coordinator-api")): trusted.append(p) sys.path = trusted diff --git a/cli/aitbc_cli/commands/exchange_island.py b/cli/aitbc_cli/commands/exchange_island.py index 6fc4b757..30b11dfc 100644 --- a/cli/aitbc_cli/commands/exchange_island.py +++ b/cli/aitbc_cli/commands/exchange_island.py @@ -198,44 +198,29 @@ def sell(ctx, ait_amount: float, quote_currency: str, min_price: Optional[float] # Submit transaction to blockchain try: - import httpx - with httpx.Client() as client: - response = client.post( - f"{rpc_endpoint}/transaction", - json=sell_order_data, - timeout=10 - ) - - if response.status_code == 200: - result = response.json() - success(f"Sell order created successfully!") - success(f"Order ID: {order_id}") - success(f"Selling {ait_amount} AIT for {quote_currency}") - - if min_price: - success(f"Min price: {min_price:.8f} {quote_currency}/AIT") - - order_info = { - "Order ID": order_id, - "Pair": pair, - "Side": "SELL", - "Amount": f"{ait_amount} AIT", - "Min Price": f"{min_price:.8f} {quote_currency}/AIT" if min_price else "Market", - "Status": "open", - "User": user_id[:16] + "...", - "Island": island_id[:16] + "..." - } - - output(order_info, ctx.obj.get('output_format', 'table')) - else: - error(f"Failed to submit transaction: {response.status_code}") - if response.text: - error(f"Error details: {response.text}") - raise click.Abort() - except Exception as e: + http_client = AITBCHTTPClient(base_url=rpc_endpoint, timeout=10) + result = http_client.post("/transaction", json=sell_order_data) + success(f"Sell order created successfully!") + success(f"Order ID: {order_id}") + success(f"Selling {ait_amount} AIT for {quote_currency}") + + if min_price: + success(f"Min price: {min_price:.8f} {quote_currency}/AIT") + + order_info = { + "Order ID": order_id, + "Pair": pair, + "Side": "SELL", + "Amount": f"{ait_amount} AIT", + "Min Price": f"{min_price:.8f} {quote_currency}/AIT" if min_price else "Market", + "Status": "open", + "User": user_id[:16] + "...", + "Island": island_id[:16] + "..." + } + output(order_info, ctx.obj.get('output_format', 'table')) + except NetworkError as e: error(f"Network error submitting transaction: {e}") raise click.Abort() - except Exception as e: error(f"Error creating sell order: {str(e)}") raise click.Abort() @@ -255,7 +240,6 @@ def orderbook(ctx, pair: str, limit: int): # Query blockchain for exchange orders try: - import httpx params = { 'transaction_type': 'exchange', 'island_id': island_id, @@ -264,41 +248,72 @@ def orderbook(ctx, pair: str, limit: int): 'limit': limit * 2 # Get both buys and sells } - with httpx.Client() as client: - response = client.get( - f"{rpc_endpoint}/transactions", - params=params, - timeout=10 - ) + http_client = AITBCHTTPClient(base_url=rpc_endpoint, timeout=10) + transactions = http_client.get("/transactions", params=params) + + # Separate buy and sell orders + buy_orders = [] + sell_orders = [] + + for order in transactions: + if order.get('side') == 'buy': + buy_orders.append(order) + elif order.get('side') == 'sell': + sell_orders.append(order) + + # Sort buy orders by price descending (highest first) + buy_orders.sort(key=lambda x: x.get('max_price', 0), reverse=True) + # Sort sell orders by price ascending (lowest first) + sell_orders.sort(key=lambda x: x.get('min_price', float('inf'))) + + if not buy_orders and not sell_orders: + info(f"No open orders for {pair}") + return + + # Display sell orders (asks) + if sell_orders: + asks_data = [] + for order in sell_orders[:limit]: + asks_data.append({ + "Price": f"{order.get('min_price', 0):.8f}", + "Amount": f"{order.get('amount', 0):.4f} AIT", + "Total": f"{order.get('min_price', 0) * order.get('amount', 0):.8f} {pair.split('/')[1]}", + "User": order.get('user_id', '')[:16] + "...", + "Order": order.get('order_id', '')[:16] + "..." + }) - if response.status_code == 200: - orders = response.json() - - # Separate buy and sell orders - buy_orders = [] - sell_orders = [] - - for order in orders: - if order.get('side') == 'buy': - buy_orders.append(order) - elif order.get('side') == 'sell': - sell_orders.append(order) - - # Sort buy orders by price descending (highest first) - buy_orders.sort(key=lambda x: x.get('max_price', 0), reverse=True) - # Sort sell orders by price ascending (lowest first) - sell_orders.sort(key=lambda x: x.get('min_price', float('inf'))) - - if not buy_orders and not sell_orders: - info(f"No open orders for {pair}") - return - - # Display sell orders (asks) - if sell_orders: - asks_data = [] - for order in sell_orders[:limit]: - asks_data.append({ - "Price": f"{order.get('min_price', 0):.8f}", + output(asks_data, ctx.obj.get('output_format', 'table'), title=f"Sell Orders (Asks) - {pair}") + + # Display buy orders (bids) + if buy_orders: + bids_data = [] + for order in buy_orders[:limit]: + bids_data.append({ + "Price": f"{order.get('max_price', 0):.8f}", + "Amount": f"{order.get('amount', 0):.4f} AIT", + "Total": f"{order.get('max_price', 0) * order.get('amount', 0):.8f} {pair.split('/')[1]}", + "User": order.get('user_id', '')[:16] + "...", + "Order": order.get('order_id', '')[:16] + "..." + }) + + output(bids_data, ctx.obj.get('output_format', 'table'), title=f"Buy Orders (Bids) - {pair}") + + # Calculate spread if both exist + if sell_orders and buy_orders: + best_ask = sell_orders[0].get('min_price', 0) + best_bid = buy_orders[0].get('max_price', 0) + spread = best_ask - best_bid + if best_bid > 0: + spread_pct = (spread / best_bid) * 100 + info(f"Spread: {spread:.8f} ({spread_pct:.4f}%)") + info(f"Best Bid: {best_bid:.8f} {pair.split('/')[1]}/AIT") + info(f"Best Ask: {best_ask:.8f} {pair.split('/')[1]}/AIT") + except NetworkError as e: + error(f"Network error fetching order book: {e}") + raise click.Abort() + except Exception as e: + error(f"Error fetching order book: {str(e)}") + raise click.Abort() "Amount": f"{order.get('amount', 0):.4f} AIT", "Total": f"{order.get('min_price', 0) * order.get('amount', 0):.8f} {pair.split('/')[1]}", "User": order.get('user_id', '')[:16] + "...",