fix(V2): map Deribit upstream 5xx / non-JSON to clean HTTPException 502

Cloudflare 5xx pages from Deribit testnet were leaking through the JSON
parser as JSONDecodeError → UNHANDLED_EXCEPTION. Wrap response parsing so
upstream errors surface as a retryable HTTP_502 envelope instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-10 08:27:33 +00:00
parent 880faa7fd4
commit f8fb50cb83
2 changed files with 46 additions and 3 deletions
+23 -3
View File
@@ -1,15 +1,35 @@
from __future__ import annotations from __future__ import annotations
import contextlib import contextlib
import json
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from fastapi import HTTPException
from cerbero_mcp.common import indicators as ind from cerbero_mcp.common import indicators as ind
from cerbero_mcp.common import microstructure as micro from cerbero_mcp.common import microstructure as micro
from cerbero_mcp.common import options as opt from cerbero_mcp.common import options as opt
from cerbero_mcp.common.http import async_client 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_LIVE = "https://www.deribit.com/api/v2"
BASE_TESTNET = "https://test.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: async with async_client(timeout=15.0) as http:
resp = await http.get(url, params=params) resp = await http.get(url, params=params)
data = resp.json() data = _parse_deribit_response(resp)
if "result" not in data: if "result" not in data:
error = data.get("error", {}) error = data.get("error", {})
msg = error.get("message", str(data)) if isinstance(error, dict) else str(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: async with async_client(timeout=15.0) as http:
resp = await http.get(url, params=request_params, headers=headers) resp = await http.get(url, params=request_params, headers=headers)
data = resp.json() data = _parse_deribit_response(resp)
if "result" not in data: if "result" not in data:
error = data.get("error", {}) error = data.get("error", {})
@@ -99,7 +119,7 @@ class DeribitClient:
await self._authenticate() await self._authenticate()
headers["Authorization"] = f"Bearer {self._token}" headers["Authorization"] = f"Bearer {self._token}"
resp = await http.get(url, params=request_params, headers=headers) resp = await http.get(url, params=request_params, headers=headers)
data = resp.json() data = _parse_deribit_response(resp)
if "result" in data: if "result" in data:
return data # type: ignore[no-any-return] return data # type: ignore[no-any-return]
return {"result": None, "error": error_msg} return {"result": None, "error": error_msg}
@@ -154,6 +154,29 @@ async def test_get_account_summary(httpx_mock: HTTPXMock, client: DeribitClient)
assert result["balance"] == 900.0 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="<html>Bad Gateway</html>",
)
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 @pytest.mark.asyncio
async def test_private_call_with_bad_auth_returns_error_envelope( async def test_private_call_with_bad_auth_returns_error_envelope(
httpx_mock: HTTPXMock, client: DeribitClient httpx_mock: HTTPXMock, client: DeribitClient