diff --git a/src/cerbero_mcp/exchanges/deribit/client.py b/src/cerbero_mcp/exchanges/deribit/client.py index 8a8dfcd..08057d2 100644 --- a/src/cerbero_mcp/exchanges/deribit/client.py +++ b/src/cerbero_mcp/exchanges/deribit/client.py @@ -1,15 +1,35 @@ from __future__ import annotations import contextlib +import json import time from dataclasses import dataclass, field from typing import Any +from fastapi import HTTPException + from cerbero_mcp.common import indicators as ind from cerbero_mcp.common import microstructure as micro from cerbero_mcp.common import options as opt from cerbero_mcp.common.http import async_client + +def _parse_deribit_response(resp) -> dict: + """Map Deribit upstream errors to a clean HTTP 502 (retryable) instead of + leaking JSONDecodeError when the body is HTML (e.g. Cloudflare 5xx page).""" + if resp.status_code >= 500: + raise HTTPException( + status_code=502, + detail=f"Deribit upstream HTTP {resp.status_code}", + ) + try: + return resp.json() + except json.JSONDecodeError as e: + raise HTTPException( + status_code=502, + detail=f"Deribit upstream returned non-JSON (status {resp.status_code})", + ) from e + BASE_LIVE = "https://www.deribit.com/api/v2" BASE_TESTNET = "https://test.deribit.com/api/v2" @@ -53,7 +73,7 @@ class DeribitClient: } async with async_client(timeout=15.0) as http: resp = await http.get(url, params=params) - data = resp.json() + data = _parse_deribit_response(resp) if "result" not in data: error = data.get("error", {}) msg = error.get("message", str(data)) if isinstance(error, dict) else str(error) @@ -87,7 +107,7 @@ class DeribitClient: async with async_client(timeout=15.0) as http: resp = await http.get(url, params=request_params, headers=headers) - data = resp.json() + data = _parse_deribit_response(resp) if "result" not in data: error = data.get("error", {}) @@ -99,7 +119,7 @@ class DeribitClient: await self._authenticate() headers["Authorization"] = f"Bearer {self._token}" resp = await http.get(url, params=request_params, headers=headers) - data = resp.json() + data = _parse_deribit_response(resp) if "result" in data: return data # type: ignore[no-any-return] return {"result": None, "error": error_msg} diff --git a/tests/unit/exchanges/deribit/test_client.py b/tests/unit/exchanges/deribit/test_client.py index 4178e11..1092eda 100644 --- a/tests/unit/exchanges/deribit/test_client.py +++ b/tests/unit/exchanges/deribit/test_client.py @@ -154,6 +154,29 @@ async def test_get_account_summary(httpx_mock: HTTPXMock, client: DeribitClient) assert result["balance"] == 900.0 +@pytest.mark.asyncio +async def test_upstream_5xx_raises_clean_http_error( + httpx_mock: HTTPXMock, client: DeribitClient +): + """Upstream Deribit 5xx (non-JSON body) → HTTPException 502, non JSONDecodeError.""" + from fastapi import HTTPException + + httpx_mock.add_response( + url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_tradingview_chart_data"), + status_code=502, + text="Bad Gateway", + ) + with pytest.raises(HTTPException) as exc_info: + await client.get_historical( + instrument="BTC-PERPETUAL", + start_date="2026-05-09T00:00:00", + end_date="2026-05-10T00:00:00", + resolution="1h", + ) + assert exc_info.value.status_code == 502 + assert "Deribit upstream" in str(exc_info.value.detail) + + @pytest.mark.asyncio async def test_private_call_with_bad_auth_returns_error_envelope( httpx_mock: HTTPXMock, client: DeribitClient