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: 21 additions & 1 deletion src/livepeer_gateway/byoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from __future__ import annotations

import base64
import http.client
import json
import logging
import ssl
Expand Down Expand Up @@ -210,8 +211,27 @@ def _create_byoc_payment(
try:
with urlopen(payment_req, timeout=timeout) as resp:
payment_data = json.loads(resp.read())
except http.client.IncompleteRead as e:
# The signer advertises a Content-Length but closes the connection
# early, so urllib raises IncompleteRead and discards the partial
# body — which usually contains the real signer error
# (e.g. {"error":{"message":"..."}}). Surface the partial bytes so
# the underlying signer failure is legible instead of an opaque
# "IncompleteRead(85 bytes read, 108 more expected)".
partial = e.partial.decode("utf-8", errors="replace")
expected = len(e.partial) + e.expected
raise LivepeerGatewayError(
f"BYOC payment: signer truncated response "
f"({len(e.partial)} of {expected} bytes); "
f"partial body: {partial!r}"
) from e
except HTTPError as e:
body = e.read().decode("utf-8", errors="replace")[:200]
try:
body = e.read().decode("utf-8", errors="replace")[:200]
except http.client.IncompleteRead as ie:
# Error responses can be truncated too — keep whatever bytes the
# signer managed to send rather than losing the message entirely.
body = ie.partial.decode("utf-8", errors="replace")
raise LivepeerGatewayError(f"BYOC payment generation failed: HTTP {e.code}: {body}") from e

result = {}
Expand Down
87 changes: 87 additions & 0 deletions tests/test_byoc_payment_truncation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""
Unit tests for the diagnostic surfacing of a truncated signer response in
``_create_byoc_payment``.

Background: the remote signer (/generate-live-payment) can advertise a
Content-Length but close the connection early, so urllib raises
``http.client.IncompleteRead`` and the partial body — which carries the real
signer error, e.g. ``{"error":{"message":"..."}}`` — is discarded. These tests
pin the behaviour that the partial bytes are captured and surfaced in the raised
``LivepeerGatewayError`` instead of the opaque ``IncompleteRead(85, 108)``.
"""
from __future__ import annotations

import http.client
from unittest.mock import MagicMock, patch
from urllib.error import HTTPError

import pytest

from livepeer_gateway.byoc import _create_byoc_payment
from livepeer_gateway.errors import LivepeerGatewayError


def _stub_orch_info():
"""OrchestratorInfo with a non-zero face_value so payment is attempted."""
info = MagicMock()
tp = MagicMock()
tp.face_value = b"\x01\x00" # non-zero
info.ticket_params = tp
info.HasField = lambda field: field == "ticket_params"
info.SerializeToString = lambda: b"stub-orch-info-protobuf"
return info


def _call():
return _create_byoc_payment(
orch_origin="https://orch.test:8936",
capability="nano-banana",
livepeer_hdr="stub-livepeer-header",
signer_url="https://signer.test",
signer_headers={"Authorization": "Bearer sk_test"},
)


def test_incomplete_read_surfaces_partial_signer_body():
"""A truncated 200 response surfaces the partial body, not IncompleteRead."""
partial = b'{"error":{"message":"ticket sender has insufficient funds'
expected_more = 108

def _fake_urlopen(req, *args, **kwargs):
raise http.client.IncompleteRead(partial, expected_more)

with patch("livepeer_gateway.byoc.urlopen", side_effect=_fake_urlopen), \
patch("livepeer_gateway.orch_info.get_orch_info",
side_effect=lambda *a, **k: _stub_orch_info()):
with pytest.raises(LivepeerGatewayError) as excinfo:
_call()

msg = str(excinfo.value)
assert "signer truncated response" in msg
assert f"{len(partial)} of {len(partial) + expected_more} bytes" in msg
# The real signer error text must now be legible in the raised error.
assert "ticket sender has insufficient funds" in msg
# Chained from the original IncompleteRead for traceability.
assert isinstance(excinfo.value.__cause__, http.client.IncompleteRead)


def test_http_error_with_truncated_body_still_surfaces_partial():
"""An error status whose body is ALSO truncated keeps the partial bytes."""
partial = b'{"error":{"message":"wallet panic"}'

def _fake_urlopen(req, *args, **kwargs):
err = HTTPError(req.full_url, 500, "internal error", {}, None)
err.read = lambda: (_ for _ in ()).throw(
http.client.IncompleteRead(partial, 40)
)
raise err

with patch("livepeer_gateway.byoc.urlopen", side_effect=_fake_urlopen), \
patch("livepeer_gateway.orch_info.get_orch_info",
side_effect=lambda *a, **k: _stub_orch_info()):
with pytest.raises(LivepeerGatewayError) as excinfo:
_call()

msg = str(excinfo.value)
assert "HTTP 500" in msg
assert "wallet panic" in msg