diff --git a/src/adcp/decisioning/upstream.py b/src/adcp/decisioning/upstream.py index eebd018c9..9e44a82fa 100644 --- a/src/adcp/decisioning/upstream.py +++ b/src/adcp/decisioning/upstream.py @@ -282,7 +282,20 @@ async def _request( ) if response.status_code == 204 or not response.content: return {} - parsed: Any = response.json() + try: + parsed: Any = response.json() + except ValueError as exc: + # Server returned a successful status with a non-JSON body (e.g. + # a proxy/CDN HTML error page). Project to SERVICE_UNAVAILABLE so + # adopters get a typed AdcpError rather than a raw JSONDecodeError. + raise AdcpError( + "SERVICE_UNAVAILABLE", + message=( + f"upstream {method} {path} returned non-JSON body " + f"(status {response.status_code}): {exc}" + ), + recovery="transient", + ) from exc return parsed async def get( diff --git a/tests/test_upstream_helpers.py b/tests/test_upstream_helpers.py index 2f1d6fd68..4059f7075 100644 --- a/tests/test_upstream_helpers.py +++ b/tests/test_upstream_helpers.py @@ -417,6 +417,22 @@ async def test_async_context_manager_closes_client() -> None: assert client._client is None # type: ignore[union-attr] +@respx.mock +async def test_200_with_malformed_json_raises_service_unavailable() -> None: + """A 2xx response with non-JSON body (e.g. CDN/proxy HTML page) must + surface as SERVICE_UNAVAILABLE, not a raw JSONDecodeError.""" + respx.get(f"{BASE}/items").mock( + return_value=httpx.Response(200, content=b"bad gateway") + ) + client = create_upstream_http_client(BASE) + with pytest.raises(AdcpError) as exc_info: + await client.get("/items") + assert exc_info.value.code == "SERVICE_UNAVAILABLE" + assert exc_info.value.recovery == "transient" + assert isinstance(exc_info.value.__cause__, json.JSONDecodeError) + await client.aclose() + + @respx.mock async def test_pool_reused_across_calls() -> None: respx.get(f"{BASE}/a").mock(return_value=httpx.Response(200, json={}))