Lukas Goldschmidt пре 1 месец
родитељ
комит
0198b37ad7
3 измењених фајлова са 85 додато и 6 уклоњено
  1. 14 3
      src/exec_mcp/crypto_client.py
  2. 14 3
      src/exec_mcp/services_bitstamp.py
  3. 57 0
      tests/test_reporting_tools.py

+ 14 - 3
src/exec_mcp/crypto_client.py

@@ -4,6 +4,7 @@ import asyncio
 import json
 import os
 import threading
+from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
 from typing import Any
 
 from mcp import ClientSession
@@ -11,6 +12,7 @@ from mcp.client.sse import sse_client
 
 
 CRYPTO_MCP_SSE_URL = os.getenv("CRYPTO_MCP_SSE_URL", "http://localhost:8505/mcp/sse")
+CRYPTO_MCP_CALL_TIMEOUT_SECONDS = float(os.getenv("CRYPTO_MCP_CALL_TIMEOUT_SECONDS", "5"))
 
 
 async def get_price(symbol: str, base_url: str | None = None) -> dict[str, Any]:
@@ -38,7 +40,14 @@ def get_price_sync(symbol: str, base_url: str | None = None) -> dict[str, Any]:
     try:
         asyncio.get_running_loop()
     except RuntimeError:
-        return asyncio.run(get_price(symbol, base_url=base_url))
+        try:
+            with ThreadPoolExecutor(max_workers=1) as pool:
+                future = pool.submit(asyncio.run, get_price(symbol, base_url=base_url))
+                return future.result(timeout=CRYPTO_MCP_CALL_TIMEOUT_SECONDS)
+        except FuturesTimeoutError:
+            return {"error": "TIMEOUT", "symbol": symbol}
+        except Exception as exc:
+            return {"error": str(exc), "symbol": symbol}
 
     result: dict[str, Any] = {}
     error: list[BaseException] = []
@@ -51,7 +60,9 @@ def get_price_sync(symbol: str, base_url: str | None = None) -> dict[str, Any]:
 
     thread = threading.Thread(target=_runner, daemon=True)
     thread.start()
-    thread.join()
+    thread.join(CRYPTO_MCP_CALL_TIMEOUT_SECONDS)
+    if thread.is_alive():
+        return {"error": "TIMEOUT", "symbol": symbol}
     if error:
-        raise error[0]
+        return {"error": str(error[0]), "symbol": symbol}
     return result

+ 14 - 3
src/exec_mcp/services_bitstamp.py

@@ -196,7 +196,10 @@ def _price_for_asset(asset_code: str) -> float | None:
         return None
     if asset == "usd":
         return 1.0
-    payload = get_crypto_price(asset)
+    try:
+        payload = get_crypto_price(asset)
+    except Exception:
+        return None
     if not isinstance(payload, dict):
         return None
     price = payload.get("price")
@@ -329,8 +332,14 @@ def fetch_account_info(account_id: str) -> dict:
             account = repo.get_account(account_id)
             balance = fetch_account_balance(account_id)
         except Exception as exc:
-            _cache_error(cache_key, str(exc))
-            raise
+            stale = repo.cache_get(_stale_key(cache_key))
+            if stale is not None:
+                return stale
+            account = repo.get_account(account_id)
+            balance = {"balances": [], "payload": None}
+            balance_error = str(exc)
+        else:
+            balance_error = None
 
         valued_balances, total_value_usd = _value_account_balances(balance)
 
@@ -346,6 +355,8 @@ def fetch_account_info(account_id: str) -> dict:
             "total_value_usd": total_value_usd,
             "raw_balance": balance["payload"],
         }
+        if balance_error is not None:
+            result["balance_error"] = balance_error
 
         repo.cache_put(
             cache_key,

+ 57 - 0
tests/test_reporting_tools.py

@@ -293,3 +293,60 @@ def test_capture_account_balance_snapshot_uses_crypto_price(temp_db, monkeypatch
 
     stored = json.loads(row["snapshot_json"])
     assert stored["balances"][0]["price_usd"] == 1.4109
+
+
+def test_get_account_info_stays_available_when_crypto_price_is_unavailable(temp_db, monkeypatch):
+    account_id = _create_account()
+
+    monkeypatch.setattr(
+        services_bitstamp,
+        "fetch_account_balance",
+        lambda _account_id: {
+            "source": "bitstamp",
+            "cached": False,
+            "balances": [
+                {
+                    "account_id": _account_id,
+                    "asset_code": "XRP",
+                    "available": 1.5,
+                    "reserved": 0.5,
+                    "total": 2.0,
+                },
+                {
+                    "account_id": _account_id,
+                    "asset_code": "USD",
+                    "available": 10.0,
+                    "reserved": 5.0,
+                    "total": 15.0,
+                },
+            ],
+            "payload": [],
+        },
+    )
+    monkeypatch.setattr(services_bitstamp, "get_crypto_price", lambda symbol: {"error": "TIMEOUT", "symbol": symbol})
+
+    result = services_bitstamp.fetch_account_info(account_id)
+
+    assert result["id"] == account_id
+    assert result["total_value_usd"] == 15.0
+    assert result["balances"][0]["asset_code"] == "XRP"
+    assert result["balances"][0]["price_usd"] is None
+    assert result["balances"][0]["value_usd"] is None
+    assert result["balances"][1]["asset_code"] == "USD"
+    assert result["balances"][1]["price_usd"] == 1.0
+    assert result["balances"][1]["value_usd"] == 15.0
+
+
+def test_get_account_info_falls_back_when_balance_fetch_fails(temp_db, monkeypatch):
+    account_id = _create_account()
+
+    monkeypatch.setattr(services_bitstamp, "fetch_account_balance", lambda _account_id: (_ for _ in ()).throw(RuntimeError("bitstamp unavailable")))
+    monkeypatch.setattr(services_bitstamp, "get_crypto_price", lambda symbol: {"symbol": symbol.upper(), "price": 1.4109, "timestamp": 1779039021})
+
+    result = services_bitstamp.fetch_account_info(account_id)
+
+    assert result["id"] == account_id
+    assert result["balances"] == []
+    assert result["total_value_usd"] == 0.0
+    assert result["raw_balance"] is None
+    assert "balance_error" in result