fix(x402-fetch): clone the request so a streamed body can pay - #306
fix(x402-fetch): clone the request so a streamed body can pay#306vilenarios wants to merge 1 commit into
Conversation
wrapFetchWithPayment built the paid retry by spreading the original
`init`, which carries the body over verbatim:
const newInit = { ...init, headers: {...}, __is402Retry: true };
const secondResponse = await fetch(input, newInit);
A body is not reusable. The unpaid attempt disturbs a ReadableStream, so
the paid attempt throws
TypeError: Response body object should not be disturbed or locked
before it is sent, and the payment can never be made. Any caller
streaming a request body — an upload, typically — cannot pay at all.
Buffered bodies survive it and are simply transmitted twice, which is why
this went unnoticed: a small JSON POST re-sends a few bytes and nobody
notices.
Build a Request up front and clone it before the first send, then retry
with the clone. `Request.clone()` tees the body so both attempts have
their own copy. This is what @x402/fetch v2 already does; this brings the
v1 package in line.
The `__is402Retry` guard is unchanged, so the existing contract still
holds. The two assertions that matched on the old (input, init) call pair
now assert on the Request instead.
Adds a test that a streamed body is paid for and that the retry still
carries the payload. It fails with the previous implementation, with the
exact TypeError above.
🟡 Heimdall Review Status
|
|
Verified this against a live service end to end, in case it's useful for review. Setup: an Arweave bundler that accepts x402 payment for uploads, on Base Sepolia.
Two details worth noting: The failure is total, not intermittent. The unpaid attempt consumes the stream, so the paid retry throws before it is ever sent — the payment can never be made. The SDK's own retry loop then burns 6 attempts re-deriving the same spent stream, which is where the 31.8s goes. The 6 MiB run is the meaningful one. It sits above that service's free-upload threshold, so it had to pay, and the on-chain USDC balance moved by exactly the quoted amount. Smaller uploads can land free and would not exercise settlement at all. Reported downstream at ardriveapp/turbo-sdk#460 — that consumer needs no changes once this lands, which is why I'd rather fix it here than work around it there. |
Problem
wrapFetchWithPaymentbuilds the paid retry by spreading the originalinit, which carries the body over verbatim:A body is not reusable. The unpaid attempt disturbs a
ReadableStream, so the paid attempt throws before it is sent:Any caller streaming a request body cannot pay at all. Buffered bodies survive it and are simply transmitted twice, which is why it has gone unnoticed — a small JSON POST re-sends a few bytes and nobody notices.
Measured across body types against a local server:
Uint8Array/Buffer/Blob/URLSearchParams/FormDataReadableStreamFix
Build a
Requestup front and clone it before the first send, then retry with the clone.Request.clone()tees the body so both attempts have their own copy.This is what
@x402/fetchv2 already does (typescript/packages/http/fetch/src/index.tsusesrequest.clone()); this brings the v1 package in line rather than inventing an approach.Compatibility
__is402Retryguard is unchanged — the existing contract holds.(input, init)call pair and now assert on theRequest. That is the only visible behavioural change: the wrappedfetchis now invoked with aRequestrather than(input, init). Callers passing a customfetchthat inspects its second argument would notice; the standardfetchsignature accepts both.Tests
Adds a test that a streamed body is paid for and that the retry still carries the payload (
bodyUsed === false, and the bytes arrive intact).I confirmed it catches the regression by reverting the fix in place — it fails with the exact
TypeErrorabove. Full suite: 7 passed,tscclean,eslintclean,prettier --checkclean.Context
Found while investigating a production incident on an Arweave bundler, where large uploads pay via x402 and the SDK streams the request body. Reported downstream at ardriveapp/turbo-sdk#460.
x402-fetchhas ~96k downloads/month, so the double-send affects every consumer sending a body, and the hard break affects anyone streaming one.Happy to adjust the approach if you'd rather callers migrate to
@x402/fetchv2 — but v1 is still widely depended on, and the fix is small.🤖 Generated with Claude Code