Prevent redirects on payment renewals and refreshes; stop funding on 3xx

Offen
#87 0 Kommentare 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen

Maintainer antworten meist innerhalb von 2 Tagen

Dieses Issue hat noch niemand übernommen.

Bewertung

Schwierigkeit
4/5
Geschätzter Aufwand
3-5 Tage
Anfängerfreundlichkeit
45/100
Issue-Typ
Bug
Klarheit
Klar beschrieben
Aktivitätsstatus
Aktiv
Tech-Stack
python
Bereich
api, backend

Rechercherichtung

Start with src/livepeer_gateway/http.py and src/livepeer_gateway/remote_signer.py to trace renewal and refresh requests through the HTTP helpers and funding loop. Add regression tests for redirect handling, terminal funding behavior, successful requests, and unchanged redirect behavior for other callers; update mock expectations as described. Run the SDK test suite and confirm 3xx responses do not reach redirect targets or trigger further ticket signing.

Vom Indexierungsmodell aus dem Issue-Text verfasst.

Beschreibung

Payment renewals currently follow redirects through _post_empty, and payment-parameter refreshes follow redirects through post_json. This proposal moves the fix to the SDK that owns those requests, following investigation in https://github.com/NE-Build-Track/livepeer-builder-core/issues/72

Problem

At 2017a80c740af5fcb0fef4ee84659af9b07fb90c, LivePaymentSession.send_payment() posts Livepeer-Payment and Livepeer-Segment through a non-stream HTTP path that follows redirects. Those custom headers can travel to the redirect target, and a downstream 2xx is treated as a successful renewal. The funding loop then requests another ticket on the next interval.

_refresh_payment_params() also follows redirects. A redirect target can supply the payment_params that the session uses for subsequent signing requests.

Both paths should refuse redirects, and a 3xx should stop funding instead of minting additional tickets. This can be implemented directly in the SDK's existing helpers and loop.

Proposal

This proposal addresses builder-core issue #72 in livepeer-python-gateway, against commit 2017a80.

The production patch adds 19 lines and removes 2 across two files, plus regression tests still to be added. It changes the existing HTTP helpers and payment loop directly.

Behavior

  • Renewal and refresh requests pass allow_redirects=False.
  • Any 3xx response on those requests raises LivepeerHTTPError immediately after the headers arrive, before reading the response body.
  • The existing run_payments() loop stops on 3xx, preventing additional tickets from being minted against a redirecting endpoint.
  • Existing handling of 404, other permanent 4xx, 408, 429, server errors, transport failures and cancellation is preserved.
  • Other callers retain their current redirect behavior. Existing stream redirect protection remains in effect.

Checking the status before reading the body prevents a truncated or stalled redirect body from turning a terminal redirect into a retryable transport error. Existing exception cleanup closes the response and session.

Changes

In remote_signer.py, disable redirects for send_payment() and _refresh_payment_params(). Extend the existing funding loop's terminal-status range from 400 <= status < 500 to 300 <= status < 500, retaining the exceptions for 408 and 429.

In http.py, add an optional allow_redirects=True argument and forward it through the existing paths:

_post_empty → _request_body → _open_request
post_json → request_json → _request_body → _open_request

In _open_request(), apply allow_redirects=False when requested and reject 3xx before reading the body. Only set the option when false so existing stream-specific redirect restrictions are preserved.

Complete production patch

The complete proposed production patch is embedded below.

--- a/src/livepeer_gateway/http.py
+++ b/src/livepeer_gateway/http.py
@@ -146,6 +146,7 @@
     payload: RunnerPayload | _StreamState = None,
     headers: dict[str, str] | None = None,
     timeout: float = 5.0,
+    allow_redirects: bool = True,
 ) -> tuple[bytes, str]:
     """
     Make an HTTP request and return the raw response body.
@@ -161,6 +162,7 @@
         session, resp = await _open_request(
             url, method=method, payload=payload, headers=headers,
             timeout=aiohttp.ClientTimeout(total=timeout), handshake_timeout=timeout,
+            allow_redirects=allow_redirects,
         )
         async with session:
             try:
@@ -205,6 +207,7 @@
     payload: dict[str, Any] | None = None,
     headers: dict[str, str] | None = None,
     timeout: float = 5.0,
+    allow_redirects: bool = True,
 ) -> Any:
     """
     Make an async JSON HTTP request and parse the JSON response.
@@ -219,6 +222,7 @@
         payload=payload,
         headers=headers,
         timeout=timeout,
+        allow_redirects=allow_redirects,
     )
     try:
         return json.loads(raw)
@@ -264,15 +268,20 @@
     headers: dict[str, str] | None,
     timeout: aiohttp.ClientTimeout,
     handshake_timeout: float,
+    allow_redirects: bool = True,
 ) -> tuple[aiohttp.ClientSession, aiohttp.ClientResponse]:
     method, headers, body = _request_parts(url, method=method, payload=payload, headers=headers)
     session, options = _request_session(payload, timeout, handshake_timeout)
+    if not allow_redirects:
+        options["allow_redirects"] = False
     if payload is None:
         # aiohttp otherwise adds application/octet-stream even for an empty POST.
         options["skip_auto_headers"] = {"Content-Type"}
     resp = None
     try:
         resp = await session.request(method, url, data=body, headers=headers, **options)
+        if not allow_redirects and 300 <= resp.status < 400:
+            _raise_http_json_error(resp.status, url)
         if _request_failed(resp.status, payload):
             # Read before cancelling the writer: aiohttp 3.13 closes the socket
             # on cancellation, which can discard a fragmented payment challenge.
@@ -296,6 +305,7 @@
     *,
     headers: dict[str, str] | None = None,
     timeout: float = 5.0,
+    allow_redirects: bool = True,
 ) -> dict[str, Any]:
     """
     POST JSON to `url` and parse a JSON object response.
@@ -305,6 +315,7 @@
         payload=payload,
         headers=headers,
         timeout=timeout,
+        allow_redirects=allow_redirects,
     )
     return _ensure_json_object(data, url=url)
 
@@ -326,6 +337,7 @@
     *,
     headers: dict[str, str] | None = None,
     timeout: float = 5.0,
+    allow_redirects: bool = True,
 ) -> None:
     """POST an empty body to ``url`` and discard the response."""
     await _request_body(
@@ -333,6 +345,7 @@
         method="POST",
         headers=headers,
         timeout=timeout,
+        allow_redirects=allow_redirects,
     )
 
 
--- a/src/livepeer_gateway/remote_signer.py
+++ b/src/livepeer_gateway/remote_signer.py
@@ -170,7 +170,9 @@
             "Livepeer-Payment": payment.payment,
             "Livepeer-Segment": payment.seg_creds,
         }
-        await _post_empty(self._challenge.payment_url, headers=headers, timeout=5.0)
+        await _post_empty(
+            self._challenge.payment_url, headers=headers, timeout=5.0, allow_redirects=False,
+        )
 
     async def run_payments(self) -> bool:
         """Keep a metered session funded until cancelled or the session ends.
@@ -186,10 +188,11 @@
             except SkipPaymentCycle as e:
                 _LOG.debug("Payment loop skipped cycle: %s", e)
             except LivepeerHTTPError as e:
+                # A redirect or permanent 4xx stops funding.
                 # A 4xx will not change on a retry (404 gone, 409 fixed price,
                 # 403 mismatch), so stop rather than mint tickets nobody will
                 # honour. 408 and 429 are the two that do ask to be retried.
-                if 400 <= e.status_code < 500 and e.status_code not in (408, 429):
+                if 300 <= e.status_code < 500 and e.status_code not in (408, 429):
                     _LOG.info("Payment loop stopping (HTTP %d): %s", e.status_code, e)
                     return e.status_code == 404
                 _LOG.warning("Payment failed; retrying next cycle: %s", e)
@@ -249,6 +252,7 @@
                 "sender": signer.address,
                 "manifest_id": self._challenge.manifest_id,
             },
+            allow_redirects=False,
         )
         payment_params = data.get("payment_params")
         if not isinstance(payment_params, str) or not payment_params:

Validation and remaining tests

The proposed code was validated in memory with 22 loopback checks, including renewal and refresh redirects, a truncated 307 response, successful refreshes, retry behavior and the default redirect behavior of other callers. The patch passes git apply --check against 2017a80.

Before merging, add permanent regression tests and run the full SDK suite. Update existing mock expectations to include allow_redirects=False on payment requests. Coverage should verify:

  • 301, 302, 303, 307 and 308 renewals reach no redirect target and stop further signing.
  • Redirected refreshes reach no target and never replace the original payment parameters.
  • A truncated or stalled 3xx body cannot trigger another payment cycle.
  • Successful renewals and refreshes still work.
  • Existing terminal-status, retry and cancellation behavior is preserved.
  • Unrelated HTTP requests retain their existing redirect behavior.

No repository source files were changed during validation. This is a proposed production patch, not a complete, tested upstream PR.

Effect on builder-core

Once builder-core consumes an SDK release or commit containing this fix, it can retain its original SDK funding path instead of a new payment module and replacement funding loop. Refresh hardening and terminal redirect handling would already be implemented in the SDK.

Consuming this upstream fix requires updating builder-core's SDK pin. Changing upstream source cannot alter the behavior of the currently pinned commit.

Vorherrschende Sprache
Python
Sterne
1
Forks
10
Ø Merge
1 T. 9 Std.
Gemergte PRs (30 T.)
7

Entwicklungsumgebung

Dieses Projekt bietet weder Dev-Container noch Dockerfile noch Beitragsleitfaden – die Einrichtung liegt bei Ihnen. Beginnen Sie mit der README; die allgemeinen Schritte stehen in unserem Leitfaden für den ersten Beitrag.

Erste Schritte

  1. Lesen Sie das ganze Issue und danach den Beitragsleitfaden des Projekts.
  2. Schreiben Sie ins Issue, dass Sie es übernehmen — das erspart doppelte Arbeit.
  3. Forken Sie das Repository und arbeiten Sie in einem Branch.
  4. Öffnen Sie einen Pull Request, der die Issue-Nummer nennt.

Mehr aus livepeer/livepeer-python-gateway

Alle Issues in livepeer/livepeer-python-gateway

Ähnliche Issues

Weitere Issues zu Python

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.