Skip to content
Open
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
22 changes: 22 additions & 0 deletions src/livepeer_gateway/orchestrator.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import http.client
import json
import logging
import ssl
Expand Down Expand Up @@ -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 ""

Expand Down Expand Up @@ -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:
Expand Down
83 changes: 83 additions & 0 deletions tests/test_request_json_truncation.py
Original file line number Diff line number Diff line change
@@ -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