Skip to content

fix(client): replay request body on paid 402 retry#156

Open
Tleaoo wants to merge 2 commits into
tempoxyz:mainfrom
Tleaoo:fix/client-retry-body
Open

fix(client): replay request body on paid 402 retry#156
Tleaoo wants to merge 2 commits into
tempoxyz:mainfrom
Tleaoo:fix/client-retry-body

Conversation

@Tleaoo

@Tleaoo Tleaoo commented Jun 24, 2026

Copy link
Copy Markdown

Bug: POST/PUT body is dropped on the paid (402) retry

Environment

  • Component: mpp.client.PaymentTransport
  • Affects: any request that carries a body (POST/PUT/PATCH) through the payment flow

Steps to reproduce

  1. Configure a Client with a payment method.
  2. await client.post(url, content=b'{"prompt": "..."}') against an endpoint
    that answers 402 Payment Required.
  3. The transport creates a credential and retries with the Authorization header.

Expected

The retried request reaches the server with the same body as the original —
that's the whole point of the flow: the paid request is the one that must
carry the payload.

Actual

The retry was built reusing the original request's stream:

retry_request = httpx.Request(..., stream=request.stream, ...)

Request bodies are streamed and one-shot. The inner transport already
consumed request.stream when it sent the initial request, so the retry went
out with an empty or malformed body. GET requests have no body, which is why the
existing GET-only tests never caught it.

Fix

Buffer the body before the first dispatch and replay it on the retry:

await request.aread()          # replaces stream with a replayable ByteStream
...
retry_request = httpx.Request(..., content=request.content, ...)

aread() reads the stream once and swaps in a replayable ByteStream, so both
the initial send and the retry transmit identical bytes and a correct
Content-Length.

Test

Added test_paid_retry_replays_request_body — POSTs a body, forces a 402, and
asserts the retried request's content and content-length match the original.
tests/test_client.py: 24 passed.

bennytimz added a commit to bennytimz/pympp that referenced this pull request Jul 7, 2026
Complements PR tempoxyz#156 (bytes/content= body replay) with two additions:

1. Multipart / files= bodies: aread() is called before the first dispatch,
   buffering the MultipartStream into request._content. The paid retry uses
   content=request.content so the full multipart payload is replayed. Tests:
   test_paid_retry_replays_multipart_body, test_paid_retry_replays_put_body,
   test_paid_retry_replays_patch_body.

2. Async generator bodies: an AsyncByteStream that is not also a SyncByteStream
   (i.e. AsyncIteratorByteStream from content=async_gen()) cannot be safely
   buffered for replay — the generator may be infinite, already partially
   consumed, or tied to a one-shot I/O source. PaymentTransport now raises
   PaymentError immediately with a clear message directing callers to use a
   buffered body instead. Test: test_paid_retry_raises_for_streaming_body.

Also includes the core fix from tempoxyz#156: await request.aread() before dispatch
and content=request.content (not stream=request.stream) on retry, so that
all finite body types are correctly replayed.
brendanjryan pushed a commit that referenced this pull request Jul 10, 2026
…166)

Complements PR #156 (bytes/content= body replay) with two additions:

1. Multipart / files= bodies: aread() is called before the first dispatch,
   buffering the MultipartStream into request._content. The paid retry uses
   content=request.content so the full multipart payload is replayed. Tests:
   test_paid_retry_replays_multipart_body, test_paid_retry_replays_put_body,
   test_paid_retry_replays_patch_body.

2. Async generator bodies: an AsyncByteStream that is not also a SyncByteStream
   (i.e. AsyncIteratorByteStream from content=async_gen()) cannot be safely
   buffered for replay — the generator may be infinite, already partially
   consumed, or tied to a one-shot I/O source. PaymentTransport now raises
   PaymentError immediately with a clear message directing callers to use a
   buffered body instead. Test: test_paid_retry_raises_for_streaming_body.

Also includes the core fix from #156: await request.aread() before dispatch
and content=request.content (not stream=request.stream) on retry, so that
all finite body types are correctly replayed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant