Skip to content

fix(x402-fetch): clone the request so a streamed body can pay - #306

Open
vilenarios wants to merge 1 commit into
coinbase:mainfrom
vilenarios:fix/x402-fetch-body-reuse
Open

fix(x402-fetch): clone the request so a streamed body can pay#306
vilenarios wants to merge 1 commit into
coinbase:mainfrom
vilenarios:fix/x402-fetch-body-reuse

Conversation

@vilenarios

Copy link
Copy Markdown

Problem

wrapFetchWithPayment builds 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 before it is sent:

TypeError: Response body object should not be disturbed or locked

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:

body paid retry
GET / string / Uint8Array / Buffer / Blob / URLSearchParams / FormData succeeds, body sent twice
ReadableStream throws — payment impossible

Fix

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 (typescript/packages/http/fetch/src/index.ts uses request.clone()); this brings the v1 package in line rather than inventing an approach.

Compatibility

  • The __is402Retry guard is unchanged — the existing contract holds.
  • Two existing assertions matched on the old (input, init) call pair and now assert on the Request. That is the only visible behavioural change: the wrapped fetch is now invoked with a Request rather than (input, init). Callers passing a custom fetch that inspects its second argument would notice; the standard fetch signature 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 TypeError above. Full suite: 7 passed, tsc clean, eslint clean, prettier --check clean.

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-fetch has ~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/fetch v2 — but v1 is still widely depended on, and the fix is small.

🤖 Generated with Claude Code

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.
@cb-heimdall

Copy link
Copy Markdown

🟡 Heimdall Review Status

Requirement Status More Info
Reviews 🟡 0/2
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 0
Global minimum 0
Max 1
1
1 if commit is unverified 1
Sum 2

@vilenarios

Copy link
Copy Markdown
Author

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. @ardrive/turbo-sdk streams the request body (an ANS-104 data item), so it hits the streamed-body path. Same code, same wallet, same endpoint each time — the only variable is which x402-fetch build is installed.

x402-fetch build result
1.2.0 as published ✗ failed after 31.8s — Response body object should not be disturbed or locked
this PR's build ✓ uploaded h72fofWVAGv9GrNAVK970W_QBQlqSSQjaP7wQUhL1WE in 2.3s
this PR's build, 6 MiB ✓ uploaded BvpfGkJOHcmVfTjhvOmKy61_OojmjaVz8LxeqUlGeb4 in 3.0s, paid 0.170831 USDC

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

2 participants