Idempotency Keys Are Not HTTP Caching
This content reflects minia2a as of its publication date and is kept for the record. Current model: x402 pay-per-call in USDC on Base only, 5 free trial calls per signed wallet, no credits and no top-up rail. Idempotency Keys Are Not HTTP Caching 2026-11-07 · implementation notes The payment-identifier extension in @x402/extensions 2.30.0 gives a buyer a way to say “this is the same payment as before”. The contract is short: situation answer same id, same request replay the first response same id, different request 409 — one id identifies one question same id, first call still executing 503 + Retry-After We wired it into every paid surface this week. The interesting part was not the cache — it was the four places where treating this as “HTTP caching with a key” produces a wrong answer that still looks successful. 1. The key is the paid question, not the route The first version keyed on the router's template. On a gateway that serves every service through one /x402/:id route, that template is the same string for all of them — so two different questions carrying one payment id would return each other's answer. It is the worst failure shape available: a byte-identical 200 that answers a question the caller did not ask, on a request that was paid for. The key has to contain the concrete path and a hash of the request body: key = (payment_id, concrete_path, sha256(method + body)) // not this: key = (payment_id, c.FullPath()) If you also cache GETs, put the query string in the hash — otherwise ?id=1 and ?id=2 share one id and one answer. 2. “Still executing” is not a conflict 409 sounds right until a client library sees it: 409 already means “your payload conflicts with the current state of the resource”, and a well-behaved client will stop and fix its payload instead of waiting. The in-flight case is a 503 with Retry-After, because the only thing the caller can do is wait for the other request. One detail worth copying: answer the retry question in the body, not only in the header. Our own refusal-shape guard caught the first version of this branch — it set Retry-After: 1 but the JSON had no machine-readable “yes, retry” field, while every other 503 in the same gateway carries one. A header is a hint; a field is an answer an agent can branch on without parsing prose. The one-line patch is queued, and the guard that caught it is the reason it was one line instead of a support thread. 3. Replay the handler's delta, not the whole header map Replaying the stored http.Header re-emits CORS and security headers the middleware already added on the way out — duplicated Access-Control-Allow-Origin, two different Content-Security-Policy values on one response. Store only the headers the handler added after the middleware chain, and replay those. Mark the replay explicitly (X-Idempotent-Replay: true). It costs one header and it is the difference between a caller debugging “why did this not execute?” and reading the answer in five seconds. 4. Advertising is a promise about every surface The extension is announced by the 402 challenge builder. If your challenge builder is called from more than one door — canonical path, an alias prefix, an MCP transport — then mounting the middleware on one door means the other doors advertise a feature they do not honour. That is worse than not advertising: a buyer who sends an id on the unhonoured door believes the payment is protected when it is not. Mount it where the challenge is built, and let the challenge be the single source of “what this endpoint supports”. 5. A replay cache is not a settlement ledger After a restart the in-flight marker is gone; the persisted replay lock is what stands between a retry and a second settlement. Ours rejects the retry with 402 rather than re-serving the answer — it does not settle twice, and it does not pretend the answer still exists. Both are honest. What is not honest is a cache that survives the process and a settlement record that does not. Bound the cache on all three axes, not just time: TTL, response body size, and total bytes. An endpoint that returns a megabyte of JSON will otherwise turn a per-process cache into a memory leak with a nice name. The rule An idempotency key identifies one paid question. The answer belongs to that question, not to the route, not to the process, and not to the header map. If any of those three is shared, the replay is a different answer wearing the same id — and it will be delivered with a success status. The extension itself is a small win for buyers: retrying across a flaky network stops being a gamble. The work is in making “the same question” mean what it says. Measured on the deployed minia2a gateway, 2026-11-07. The middleware source is internal/httpapi/payment_idem.go; the queued one-line retry-field patch is patches/2026-11-07-payment-idem-503-retry-flag/. Catalog size at measurement time: 1,842 entries (/api/stats). Originally published at minia2a.uk.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to