Skip to content

Commit 9cd3c07

Browse files
rickstaaclaude
andcommitted
fix: pass through a response that is JSON but not an object
call_runner picked its return path from Content-Type alone, so a body that is valid JSON but not an object matched neither: it took the JSON branch, failed the isinstance check, and raised. A runner proxying somebody else's API does not choose its response shape, and a top-level array is a common one, so it now comes back unparsed in `content` the way an image or ndjson already does. Objects are unchanged, session_id included, and the only path that behaves differently is the one that used to raise. Strictness stays where protocol fields are read out of the body: proxy create and trickle channel remove. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a5f0f6d commit 9cd3c07

3 files changed

Lines changed: 51 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The
44
format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/).
66

7+
## [Unreleased]
8+
9+
### Fixed
10+
11+
- `call_runner` no longer raises on a response that is valid JSON but not an
12+
object. A top-level array (or scalar) is handed back unparsed in
13+
`result.content` with `result.content_type` intact, as an image or ndjson
14+
already is, so a runner can pass through an API that answers with one.
15+
716
## [1.0.0] - 2026-08-11
817

918
The first stable release of the Livepeer Python SDK.

src/livepeer_gateway/live_runner.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,8 @@ class LiveRunnerCallResult:
173173
repr=False,
174174
compare=False,
175175
)
176-
# Non-JSON responses (an image, say) arrive unparsed in `content`; `data` stays empty.
176+
# Responses that are not a JSON object (an image, a JSON array) arrive unparsed
177+
# in `content`; `data` stays empty.
177178
content: bytes | None = field(default=None, repr=False)
178179
content_type: str = ""
179180

@@ -809,8 +810,8 @@ async def call_runner(
809810
paid via the signer and retried (up to ``max_payment_challenge_retries``), one job,
810811
one upfront payment. Raises ``LivepeerHTTPError`` on non-402 errors.
811812
812-
``application/json`` and ``+json`` types parse into ``result.data``; anything else
813-
(an image, ndjson) comes back unparsed in ``result.content`` + ``result.content_type``.
813+
A JSON *object* parses into ``result.data``; anything else (an image, ndjson, a
814+
top-level JSON array) comes back unparsed in ``result.content`` + ``result.content_type``.
814815
815816
The request asks for no particular format, so the app picks what it returns.
816817
"""
@@ -913,16 +914,21 @@ async def call_runner(
913914
data: dict[str, Any] = {}
914915
if is_json:
915916
try:
916-
data = json.loads(body)
917+
parsed = json.loads(body)
917918
except (UnicodeDecodeError, json.JSONDecodeError) as e:
918919
raise LivepeerGatewayError(
919920
f"HTTP JSON error: endpoint did not return valid JSON: {e} "
920921
f"(url={runner_url}, content_type={content_type})"
921922
) from e
922-
if not isinstance(data, dict):
923-
raise LivepeerGatewayError(
924-
f"Live runner call expected JSON object, got {type(data).__name__}"
925-
)
923+
# Only an object can carry the protocol fields read below, so
924+
# anything else is payload rather than a reply this call speaks:
925+
# hand it back unparsed, as ndjson and binary already are. A
926+
# runner proxying somebody else's API does not choose its
927+
# response shape, and a top-level array is a common one.
928+
if isinstance(parsed, dict):
929+
data = parsed
930+
else:
931+
is_json = False
926932
return LiveRunnerCallResult(
927933
data,
928934
runner_url=runner_url,

tests/test_call_runner_raw.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"""Tests for non-JSON (raw byte) responses in call_runner.
22
3-
Single-document JSON responses (``application/json`` or an RFC 6839 ``+json``
4-
suffix) keep today's behavior: parsed into ``result.data``, strict about being an
5-
object. Anything elsebinary, or a multi-document format like ndjson — returns
6-
the body unparsed in ``result.content`` with ``result.content_type`` set.
3+
A JSON *object* (``application/json`` or an RFC 6839 ``+json`` suffix) parses into
4+
``result.data``. Anything else — binary, a multi-document format like ndjson, or a
5+
top-level JSON arrayreturns the body unparsed in ``result.content`` with
6+
``result.content_type`` set.
77
"""
88

99
from __future__ import annotations
@@ -154,18 +154,38 @@ async def scenario(base):
154154
assert all("did not return valid JSON" in str(error) for error in errors)
155155

156156

157-
def test_json_array_still_rejected():
157+
def test_json_array_returns_raw():
158+
"""A top-level array is data, not a reply this call speaks: hand it back whole."""
159+
158160
async def handler(request):
159-
return web.json_response([1, 2, 3])
161+
return web.json_response([{"label": "llama", "score": 0.99}])
160162

161163
app = web.Application()
162164
app.router.add_post("/arr", handler)
163165

164166
async def scenario(base):
165167
return await call_runner(f"{base}/arr", payload={})
166168

167-
with pytest.raises(LivepeerGatewayError, match="expected JSON object"):
168-
_run(app, scenario)
169+
result = _run(app, scenario)
170+
assert result.data == {}
171+
assert result.content == b'[{"label": "llama", "score": 0.99}]'
172+
assert result.content_type == "application/json" # still says what it is
173+
assert result.session_id == ""
174+
175+
176+
def test_json_scalar_returns_raw():
177+
async def handler(request):
178+
return web.json_response("just a string")
179+
180+
app = web.Application()
181+
app.router.add_post("/scalar", handler)
182+
183+
async def scenario(base):
184+
return await call_runner(f"{base}/scalar", payload={})
185+
186+
result = _run(app, scenario)
187+
assert result.data == {}
188+
assert result.content == b'"just a string"'
169189

170190

171191
def test_http_error_still_raises_with_binary_endpoint():

0 commit comments

Comments
 (0)