From 21186a5506b13f68986a323662f25c122328cb63 Mon Sep 17 00:00:00 2001 From: seanhanca <103605970+seanhanca@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:35:11 -0700 Subject: [PATCH] fix(http): surface truncated response body in shared request_json helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalizes PR #38 (which fixed only byoc.py _create_byoc_payment) to the shared HTTP helper in orchestrator.py, which powers discovery, orchestrator selection, LV2V start, and signer session/refresh via request_json/get_json/ post_json. Root cause: http.client.IncompleteRead subclasses HTTPException — NOT HTTPError/URLError/OSError — so a truncated response (endpoint advertises a Content-Length but closes the connection early) slips past every existing handler and surfaces opaquely as "IncompleteRead(85, 108)", discarding the partial body that carries the real error (e.g. {"error":{"message":"..."}}). - request_json: catch IncompleteRead on the success read and re-raise the captured partial body inside LivepeerGatewayError with byte counts. - _http_error_body: fall back to e.partial when the error-body read is itself truncated, so _extract_error_message can still recover the real message. Error-legibility hardening only: happy-path behaviour is unchanged. Does not migrate byoc.py's inline urlopen call sites (deferred, larger scope). Co-authored-by: Cursor --- src/livepeer_gateway/orchestrator.py | 22 +++++++ tests/test_request_json_truncation.py | 83 +++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 tests/test_request_json_truncation.py diff --git a/src/livepeer_gateway/orchestrator.py b/src/livepeer_gateway/orchestrator.py index 844cd7b..8f5216d 100644 --- a/src/livepeer_gateway/orchestrator.py +++ b/src/livepeer_gateway/orchestrator.py @@ -1,5 +1,6 @@ from __future__ import annotations +import http.client import json import logging import ssl @@ -37,6 +38,12 @@ def _http_error_body(e: HTTPError) -> str: if isinstance(b, bytes): return b.decode("utf-8", errors="replace") return str(b) + except http.client.IncompleteRead as ie: + # Error bodies can be truncated too (endpoint advertises a + # Content-Length but closes the connection early). Keep whatever + # bytes arrived rather than discarding the real error entirely, so + # _extract_error_message can still recover {"error":{"message":...}}. + return ie.partial.decode("utf-8", errors="replace") except Exception: return "" @@ -127,6 +134,21 @@ def request_json( raise LivepeerGatewayError( f"HTTP JSON error: failed to reach endpoint: {getattr(e, 'reason', e)} (url={url})" ) from e + except http.client.IncompleteRead as e: + # The endpoint advertises a Content-Length but closes the connection + # early, so urllib raises IncompleteRead and discards the partial + # body — which usually carries the real error (e.g. + # {"error":{"message":"..."}}). IncompleteRead subclasses + # HTTPException (NOT HTTPError/URLError/OSError), so it slips past the + # handlers above; surface the partial bytes so the underlying failure + # is legible instead of an opaque "IncompleteRead(85, 108)". + partial = e.partial.decode("utf-8", errors="replace") + expected = len(e.partial) + e.expected + raise LivepeerGatewayError( + f"HTTP JSON error: endpoint truncated response " + f"({len(e.partial)} of {expected} bytes); " + f"partial body: {partial!r} (url={url})" + ) from e except json.JSONDecodeError as e: raise LivepeerGatewayError(f"HTTP JSON error: endpoint did not return valid JSON: {e} (url={url})") from e except Exception as e: diff --git a/tests/test_request_json_truncation.py b/tests/test_request_json_truncation.py new file mode 100644 index 0000000..fc2aefd --- /dev/null +++ b/tests/test_request_json_truncation.py @@ -0,0 +1,83 @@ +""" +Unit tests for the shared HTTP helper's handling of truncated responses. + +Background: ``http.client.IncompleteRead`` subclasses ``HTTPException`` — NOT +``HTTPError`` / ``URLError`` / ``OSError`` — so a truncated response (endpoint +advertises a Content-Length but closes the connection early) slips past every +handler in ``orchestrator.request_json`` and previously surfaced as an opaque +``IncompleteRead(85, 108)`` with the partial body (which carries the real +error, e.g. ``{"error":{"message":"..."}}``) discarded. + +These tests pin the hardening that generalizes PR #38's fix to the shared +helper, covering both the success-body read and the error-body read. +""" +from __future__ import annotations + +import http.client +from unittest.mock import patch +from urllib.error import HTTPError + +import pytest + +from livepeer_gateway.errors import LivepeerGatewayError +from livepeer_gateway.orchestrator import request_json + + +class _TruncatingResponse: + """Context-manager response whose .read() raises IncompleteRead.""" + + def __init__(self, partial: bytes, expected_more: int): + self._exc = http.client.IncompleteRead(partial, expected_more) + + def read(self): + raise self._exc + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + +def test_request_json_surfaces_truncated_success_body(): + """A truncated 200 body surfaces the partial bytes, not IncompleteRead.""" + partial = b'{"error":{"message":"ticket sender has insufficient funds' + expected_more = 108 + url = "https://orch.test/discover" + + def _fake_urlopen(req, *args, **kwargs): + return _TruncatingResponse(partial, expected_more) + + with patch("livepeer_gateway.orchestrator.urlopen", side_effect=_fake_urlopen): + with pytest.raises(LivepeerGatewayError) as excinfo: + request_json(url) + + msg = str(excinfo.value) + assert "truncated response" in msg + assert f"{len(partial)} of {len(partial) + expected_more} bytes" in msg + assert "ticket sender has insufficient funds" in msg + assert url in msg + assert isinstance(excinfo.value.__cause__, http.client.IncompleteRead) + + +def test_request_json_keeps_truncated_error_body(): + """An error status whose body is ALSO truncated keeps the partial bytes.""" + partial = b'{"error":{"message":"wallet panic"}' + url = "https://orch.test/discover" + + def _fake_urlopen(req, *args, **kwargs): + err = HTTPError(url, 500, "internal error", {}, None) + err.read = lambda: (_ for _ in ()).throw( + http.client.IncompleteRead(partial, 40) + ) + raise err + + with patch("livepeer_gateway.orchestrator.urlopen", side_effect=_fake_urlopen): + with pytest.raises(LivepeerGatewayError) as excinfo: + request_json(url) + + msg = str(excinfo.value) + assert "HTTP 500" in msg + # The real error text recovered from the partial body must be legible. + assert "wallet panic" in msg + assert url in msg