Hacktoberfest 2026: as issues que os mantenedores marcaram para outubro, abertas e boas para iniciantes. Ver issues do Hacktoberfest

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

Aberta
#87 0 comentários 0 reações 0 responsáveis Ver no GitHub

Mantenedores costumam responder em até 2 dias

Ninguém assumiu esta issue ainda.

Avaliação

Dificuldade
4/5
Tempo estimado
3-5 dias
Facilidade para iniciantes
45/100
Tipo de issue
Bug
Clareza
Claramente especificada
Status de atividade
Ativa
Stack de tecnologia
python
Domínio
api, backend

Direção de pesquisa

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.

Escrita pelo modelo de indexação a partir do texto da issue.

Descrição

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.

Linguagem predominante
Python
Estrelas
1
Forks
10
Merge médio
1d 9h
PRs com merge (30d)
7

Preparar o ambiente

Este projeto não oferece contêiner de desenvolvimento, Dockerfile nem guia de contribuição, então a configuração fica por sua conta: comece pelo README e veja nosso guia da primeira contribuição para os passos gerais.

Primeiros passos

  1. Leia a issue inteira e depois o guia de contribuição do projeto.
  2. Comente na issue dizendo que vai assumir — evita que duas pessoas façam o mesmo trabalho.
  3. Faça um fork do repositório e trabalhe em uma branch.
  4. Abra um pull request que referencie o número da issue.

Mais de livepeer/livepeer-python-gateway

Todas as issues de livepeer/livepeer-python-gateway

Issues semelhantes

Mais issues de Python

Receba novas issues na sua caixa de entrada

Um resumo curto de issues do GitHub para quem está começando.