Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 14 additions & 8 deletions src/livepeer_gateway/live_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""

Expand Down Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 28 additions & 8 deletions tests/test_call_runner_raw.py
Original file line number Diff line number Diff line change
@@ -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 elsebinary, 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 arrayreturns the body unparsed in ``result.content`` with
``result.content_type`` set.
"""

from __future__ import annotations
Expand Down Expand Up @@ -154,18 +154,38 @@ 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)

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():
Expand Down