diff --git a/CHANGELOG.md b/CHANGELOG.md index 67074c8..c604f9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Fixed + +- `call_runner` no longer raises on a response that is valid JSON but not an + object. A top-level array (or scalar) is handed back unparsed in + `result.content` with `result.content_type` intact, as an image or ndjson + already is, so a runner can pass through an API that answers with one. + ## [1.0.0] - 2026-08-11 The first stable release of the Livepeer Python SDK. diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 75faa28..99e2fe7 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -173,7 +173,8 @@ class LiveRunnerCallResult: repr=False, compare=False, ) - # Non-JSON responses (an image, say) arrive unparsed in `content`; `data` stays empty. + # Responses that are not a JSON object (an image, a JSON array) arrive unparsed + # in `content`; `data` stays empty. content: bytes | None = field(default=None, repr=False) content_type: str = "" @@ -809,8 +810,8 @@ async def call_runner( paid via the signer and retried (up to ``max_payment_challenge_retries``), one job, one upfront payment. Raises ``LivepeerHTTPError`` on non-402 errors. - ``application/json`` and ``+json`` types parse into ``result.data``; anything else - (an image, ndjson) comes back unparsed in ``result.content`` + ``result.content_type``. + A JSON *object* parses into ``result.data``; anything else (an image, ndjson, a + top-level JSON array) comes back unparsed in ``result.content`` + ``result.content_type``. The request asks for no particular format, so the app picks what it returns. """ @@ -913,16 +914,21 @@ async def call_runner( data: dict[str, Any] = {} if is_json: try: - data = json.loads(body) + parsed = json.loads(body) except (UnicodeDecodeError, json.JSONDecodeError) as e: raise LivepeerGatewayError( f"HTTP JSON error: endpoint did not return valid JSON: {e} " f"(url={runner_url}, content_type={content_type})" ) from e - if not isinstance(data, dict): - raise LivepeerGatewayError( - f"Live runner call expected JSON object, got {type(data).__name__}" - ) + # Only an object can carry the protocol fields read below, so + # anything else is payload rather than a reply this call speaks: + # hand it back unparsed, as ndjson and binary already are. A + # runner proxying somebody else's API does not choose its + # response shape, and a top-level array is a common one. + if isinstance(parsed, dict): + data = parsed + else: + is_json = False return LiveRunnerCallResult( data, runner_url=runner_url, diff --git a/tests/test_call_runner_raw.py b/tests/test_call_runner_raw.py index 213b8d4..bcbb9a9 100644 --- a/tests/test_call_runner_raw.py +++ b/tests/test_call_runner_raw.py @@ -1,9 +1,9 @@ """Tests for non-JSON (raw byte) responses in call_runner. -Single-document JSON responses (``application/json`` or an RFC 6839 ``+json`` -suffix) keep today's behavior: parsed into ``result.data``, strict about being an -object. Anything else — binary, or a multi-document format like ndjson — returns -the body unparsed in ``result.content`` with ``result.content_type`` set. +A JSON *object* (``application/json`` or an RFC 6839 ``+json`` suffix) parses into +``result.data``. Anything else — binary, a multi-document format like ndjson, or a +top-level JSON array — returns the body unparsed in ``result.content`` with +``result.content_type`` set. """ from __future__ import annotations @@ -154,9 +154,11 @@ async def scenario(base): assert all("did not return valid JSON" in str(error) for error in errors) -def test_json_array_still_rejected(): +def test_json_array_returns_raw(): + """A top-level array is data, not a reply this call speaks: hand it back whole.""" + async def handler(request): - return web.json_response([1, 2, 3]) + return web.json_response([{"label": "llama", "score": 0.99}]) app = web.Application() app.router.add_post("/arr", handler) @@ -164,8 +166,26 @@ async def handler(request): async def scenario(base): return await call_runner(f"{base}/arr", payload={}) - with pytest.raises(LivepeerGatewayError, match="expected JSON object"): - _run(app, scenario) + result = _run(app, scenario) + assert result.data == {} + assert result.content == b'[{"label": "llama", "score": 0.99}]' + assert result.content_type == "application/json" # still says what it is + assert result.session_id == "" + + +def test_json_scalar_returns_raw(): + async def handler(request): + return web.json_response("just a string") + + app = web.Application() + app.router.add_post("/scalar", handler) + + async def scenario(base): + return await call_runner(f"{base}/scalar", payload={}) + + result = _run(app, scenario) + assert result.data == {} + assert result.content == b'"just a string"' def test_http_error_still_raises_with_binary_endpoint():