Summary
A buyer is charged when an agent upstream fails internally but still returns HTTP 200. Observed live on Base mainnet: an agent's LLM provider returned a non-retryable 404, the agent aborted its conversation loop, returned an empty reply with 200 application/json, and the payment settled on-chain. The buyer paid and received nothing.
Root cause
The settle decision is not "status < 400 after the response is known" — on the non-SSE path it happens before the body exists at all.
settlementInterceptor.WriteHeader calls settleFunc() at internal/x402/forwardauth.go:1311, i.e. the moment ReverseProxy hands over the upstream response headers. The only gate before it is statusCode >= 400 (internal/x402/forwardauth.go:1286).
- The body-liveness guard that would catch an empty answer,
bytesWritten == 0 (internal/x402/forwardauth.go:1347), is unreachable on this path: finalize returns immediately unless streamDefer is set (internal/x402/forwardauth.go:1324), and streamDefer is only set for text/event-stream (internal/x402/forwardauth.go:1297-1305).
So any upstream that reports its own failure inside a 200 is settled as a success.
Both request shapes are affected
stream: false — settles at WriteHeader, before any body byte. This is the shape that produced the incident.
stream: true (SSE) — reaches finalize, but a role-only delta plus data: [DONE] is roughly 60 bytes, which clears bytesWritten == 0 and settles too.
A fix that covers only one shape is not a fix.
It is recorded as a clean sale
chargedRequests increments (internal/x402/verifier.go:495-498) while upstreamFailedAfterVerify does not (internal/x402/verifier.go:499-503 requires status >= 400), so the failure is invisible in metrics.
Related defect found while investigating
settlementInterceptor.Flush (internal/x402/forwardauth.go:1362-1366) is an unconditional passthrough, and statusRecorder.Flush (internal/x402/verifier.go:1020-1024) forwards it to the real writer. httputil sets flushInterval = -1 for any response with ContentLength == -1 — chunked, not only SSE — and copyResponse arms delayedFlush before the first body byte.
A chunked application/json reply therefore commits an implicit 200 while wroteStatus is still false, which:
- silently drops the
X-PAYMENT-RESPONSE / PAYMENT-RESPONSE receipt set later at internal/x402/forwardauth.go:862-865, leaving the buyer to log "auth consumed without observed settlement" (internal/x402/buyer/proxy.go:741-748) — charged on-chain with no receipt to reconcile against;
- defeats the
wroteStatus guards at internal/x402/forwardauth.go:826 and :847, so a settle failure cannot flip the status to 503.
No existing test catches this: httptest handlers with small bodies get an automatic Content-Length, so flushInterval is 0. Any buffering scheme added here must gate Flush.
Proposed direction
Settle only when the upstream actually delivered a completion, behind an opt-in ForwardAuthConfig flag so existing behaviour is unchanged by default:
- For non-SSE chat-shaped routes, hold status and body until
finalize and decide there.
- For SSE, tap the stream to detect whether any content delta was delivered — without buffering or delaying a byte, so incremental streaming timing is preserved.
- Treat a zero-byte body as "no value" before attempting to parse it —
json.Unmarshal([]byte(""), …) returns an error, so a naive parse-then-fail-open settles the exact empty-body case in this report.
- Gate
Flush under any buffering mode (see above).
Two-phase auth-capture (authorize now, capture once the answer is known good) is not available as an alternative: the facilitator rejects it with ERR_TWO_PHASE_NOT_SUPPORTED, and autoCapture: true is hardcoded at internal/x402/authcapture.go:142.
Impact
Any paid agent or inference offer whose upstream can fail while returning 2xx. With the platform fee enabled, the fee splits on-chain for the failed request too.
Summary
A buyer is charged when an agent upstream fails internally but still returns HTTP 200. Observed live on Base mainnet: an agent's LLM provider returned a non-retryable 404, the agent aborted its conversation loop, returned an empty reply with
200 application/json, and the payment settled on-chain. The buyer paid and received nothing.Root cause
The settle decision is not "status < 400 after the response is known" — on the non-SSE path it happens before the body exists at all.
settlementInterceptor.WriteHeadercallssettleFunc()atinternal/x402/forwardauth.go:1311, i.e. the momentReverseProxyhands over the upstream response headers. The only gate before it isstatusCode >= 400(internal/x402/forwardauth.go:1286).bytesWritten == 0(internal/x402/forwardauth.go:1347), is unreachable on this path:finalizereturns immediately unlessstreamDeferis set (internal/x402/forwardauth.go:1324), andstreamDeferis only set fortext/event-stream(internal/x402/forwardauth.go:1297-1305).So any upstream that reports its own failure inside a 200 is settled as a success.
Both request shapes are affected
stream: false— settles atWriteHeader, before any body byte. This is the shape that produced the incident.stream: true(SSE) — reachesfinalize, but a role-only delta plusdata: [DONE]is roughly 60 bytes, which clearsbytesWritten == 0and settles too.A fix that covers only one shape is not a fix.
It is recorded as a clean sale
chargedRequestsincrements (internal/x402/verifier.go:495-498) whileupstreamFailedAfterVerifydoes not (internal/x402/verifier.go:499-503requires status >= 400), so the failure is invisible in metrics.Related defect found while investigating
settlementInterceptor.Flush(internal/x402/forwardauth.go:1362-1366) is an unconditional passthrough, andstatusRecorder.Flush(internal/x402/verifier.go:1020-1024) forwards it to the real writer.httputilsetsflushInterval = -1for any response withContentLength == -1— chunked, not only SSE — andcopyResponsearmsdelayedFlushbefore the first body byte.A chunked
application/jsonreply therefore commits an implicit200whilewroteStatusis still false, which:X-PAYMENT-RESPONSE/PAYMENT-RESPONSEreceipt set later atinternal/x402/forwardauth.go:862-865, leaving the buyer to log "auth consumed without observed settlement" (internal/x402/buyer/proxy.go:741-748) — charged on-chain with no receipt to reconcile against;wroteStatusguards atinternal/x402/forwardauth.go:826and:847, so a settle failure cannot flip the status to 503.No existing test catches this:
httptesthandlers with small bodies get an automaticContent-Length, soflushIntervalis 0. Any buffering scheme added here must gateFlush.Proposed direction
Settle only when the upstream actually delivered a completion, behind an opt-in
ForwardAuthConfigflag so existing behaviour is unchanged by default:finalizeand decide there.json.Unmarshal([]byte(""), …)returns an error, so a naive parse-then-fail-open settles the exact empty-body case in this report.Flushunder any buffering mode (see above).Two-phase auth-capture (authorize now, capture once the answer is known good) is not available as an alternative: the facilitator rejects it with
ERR_TWO_PHASE_NOT_SUPPORTED, andautoCapture: trueis hardcoded atinternal/x402/authcapture.go:142.Impact
Any paid agent or inference offer whose upstream can fail while returning 2xx. With the platform fee enabled, the fee splits on-chain for the failed request too.