Idempotent HTTP Methods & Safe Retries
RFC 9110 — safe vs idempotent. GET/HEAD/OPTIONS/TRACE safe; PUT/DELETE idempotent but not safe; POST not idempotent; PATCH depends. Idempotency-Key still needed for POST and for concurrent PUT races.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Why method semantics are not enough for POST charges
Prefer
Honor RFC 9110, then add a key where the spec is silent
PUT/DELETE retries are the same resource state. POST is a new intent every time unless the client names it with Idempotency-Key. Concurrent PUTs still race.
- Clients can retry GET/PUT/DELETE without inventing a protocol — if they send the same target and body.
- POST /orders still needs a key; the URL does not identify the order yet.
- Two in-flight PUTs with different bodies last-write-wins; a key plus fingerprint freezes the first intent.
- PATCH is only as idempotent as the patch document you document.
Alternative
Just use PUT for everything / never send a key
PUT idempotency is replace-by-URL. Creating a charge is not a replace. Timeouts still race.
- Forcing clients to mint /charges/{id} up front leaks id generation into every SDK.
- Method idempotence does not serialize concurrent writers.
- A timeout retry of POST without a key is a second order.
- Intermediaries that retry POST automatically are a spec violation and a production bug.
Timeout, then retry — what is legal?
Vertical cards for phones. The sequence diagram below is the same retry.
- 1
Classify the method
Safe? Idempotent? Neither? That decides whether a proxy may retry without asking the application. - 2
GET /users/123
Safe. Retry freely (still jitter — storms are real). No state change. ETag / If-None-Match if you cache. - 3
PUT /users/123 same body
Idempotent upsert. Retry the same URL and representation. Resource ends in that state once or many times. - 4
POST /orders no key
Each successful retry appends a new order. This is the double-submit bug. - 5
POST /orders plus Idempotency-Key
Server claims the key, runs once, replays. Timeout becomes effectively-once. 409 if still in flight. - 6
Two concurrent PUTs, different bodies
HTTP does not order them. Last write wins unless you add a key, If-Match ETag, or a version column.
Overview
Idempotence means repeating the same HTTP request yields the same observable effect as sending it once. Safe means the request is not intended to change server state at all.
RFC 9110 splits the two. All safe methods are idempotent. Not all idempotent methods are safe. That sentence is the interview.
| Method | RFC 9110 idea | Typical use | Idempotent? | Safe? |
|---|---|---|---|---|
| GET | Retrieve a representation, no intended side effects | Reads | yes | yes |
| HEAD | GET without a body | Caching, health, CORS size checks | yes | yes |
| OPTIONS | Discover allowed methods / CORS preflight | Introspection | yes | yes |
| TRACE | Echo for diagnostics | Debugging only; often disabled | yes | yes |
| PUT | Replace the target resource with this representation | Create-or-replace, full update | yes | no |
| DELETE | Remove the target resource | Deletes, token revoke | yes | no |
| PATCH | Apply a partial modification | Sparse update, JSON Patch | depends | no |
| POST | Create a subordinate, or trigger processing | Commands, collections, RPC-in-HTTP | no | no |
| Idempotency-Key | Application header, not an HTTP method | Exactly-once for POST (and racy PUT) | adds a layer | n/a |
“Observable effect” is the load-bearing phrase. A GET that logs an access row is still safe in the RFC sense if logging is not the request’s contract. A GET that increments a view counter is a spec smell — you have hidden a POST inside a GET, and caches / prefetchers will fire it.
PUT vs POST
PUT is create-or-replace. PUT /users/123 with body {name: "Ada"} creates user 123 if missing or replaces the representation if present. Sending it five times after a timeout leaves one user 123 in that state. The client must know the resource identifier before the call.
POST is append or process. POST /orders creates a new order each successful call, even if the JSON is identical. The server mints order_id. That is why payment APIs are POST + Idempotency-Key, not PUT.
PUT /users/123 → the user named 123 is this document
POST /users → please create a user; here is a suggestion
POST /users/123/orders → append an order under this userDELETE is idempotent
Deleting /users/123 twice: first call removes it (200/204), second call finds nothing. Returning 404 on the second call is still idempotent if the resource stays gone. Some APIs return 204 for both to make clients simpler. Either is compatible with “the effect of N DELETEs is the same as one.” What is not idempotent: DELETE /users?limit=1 (a new victim each time) or POST /users/123/delete.
Deep dive · Same status code is not the definition
RFC 9110 talks about observable side effects, not byte-identical responses. A first PUT may return 201 (created) and a retry 200 (replaced) — still idempotent if the stored representation is the same. A first DELETE 204 and a retry 404 is still idempotent if the resource remains absent. Clients that branch on 201 vs 200 should treat both as “the resource now matches the body.” Cached Idempotency-Key replays are the exception: those should return the original status so the SDK is deterministic.
Concurrent PUT is the race HTTP does not solve
Idempotent means “the same request, repeated.” It does not mean “two different PUTs to the same URL serialize.”
Sequence
- 1
Tab one → Service
PUT /carts/me body A
- 2
Tab two → Service
PUT /carts/me body B
- 3
Service
no ordering in HTTP
- 4
Service → Tab one
200 stored A
- 5
Service → Tab two
200 stored B
- 6
Service
last writer wins — A is gone
Lesson map
Idempotent HTTP Methods & Safe Retries
RFC 9110 — safe vs idempotent. GET/HEAD/OPTIONS/TRACE safe; PUT/DELETE idempotent but not safe; POST not idempotent; PATCH depends. Idempotency-Key still needed for POST and for concurrent PUT races.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB t1["Tab one"] t2["Tab two"] s["Service"] t1 -->|PUT /carts/me| s t2 -->|PUT /carts/me| s s -->|200 stored A| t1 s -->|200 stored B| t2
Mitigations, pick one and document it:
| Guard | What it does |
|---|---|
If-Match / ETag | Second PUT with a stale tag gets 412. Client re-GETs and decides. |
| Version column | UPDATE … WHERE version = $expected; 0 rows → 409. |
| Idempotency-Key + fingerprint | First intent is stored; a different body under the same key is 422; a new key is a new intent (last-write if you allow it). |
| POST a command | POST /carts/me/items with a key, instead of replacing the whole cart. |
Without one of these, “PUT is idempotent” is true per body and false per URL under concurrency. Interviewers follow up here.
HEAD, OPTIONS, and what a proxy may retry
- HEAD is GET minus the body. Caches and load balancers use it. It must not charge a card.
- OPTIONS is CORS and “what methods exist.” Safe.
- RFC 9110 lets intermediaries retry safe methods after a connection failure. It does not let them retry POST. If your ALB has “retry 5xx” for all methods, turn that off for POST/PATCH or you have a second, key-less client.
What the load balancer is allowed to replay
Method class first, then application keys. Proxies do not see your Idempotency-Key protocol unless you taught them.
- 1
Safe (GET HEAD OPTIONS)
Proxy may retry. Still add jitter client-side so a 503 does not become a read storm. - 2
Idempotent writes (PUT DELETE)
Same URL and same body: retry is spec-legal. Confirm the ALB does not strip or alter the body. - 3
POST and most PATCH
Proxy must not retry. The client retries with the same Idempotency-Key. - 4
409 in-flight
Not a proxy problem. Wait Retry-After and resend the same key. Never mint a new key.
PATCH caveats
PATCH is defined by the media type:
| Patch | Idempotent? | Why |
|---|---|---|
{"status": "paid"} (JSON merge, set-to-constant) | Usually yes | Re-applying the same set is the same state |
JSON Patch {op: replace, path: /name, value: Ada} | Yes | Same replace |
{op: add, path: /tags/-, value: x} | No | Each apply appends another tag |
{op: increment, path: /balance, value: 10} | No | N retries add 10N |
The API contract must say which patches are idempotent. If you cannot, require an Idempotency-Key or switch the operation to PUT of the full resource.
When Idempotency-Key is still needed
Even with an idempotent method:
- POST — always, if a retry must not create a second entity.
- Concurrent PUT with different bodies — two writers, one URL, last-write-wins. A key scoped to this client’s intent (or
If-Match/ version) makes the first body stick and the second 412/422. - PATCH that is not intrinsically idempotent.
- POST that is actually RPC (
POST /charge,POST /refund) — method theater; treat as non-idempotent.
The key does not replace HTTP semantics. It is a second layer when the method is silent or when races exist. Fingerprint the body so the same key cannot change amount — fingerprinting.
Sequence
- 1
Client → Load balancer
request (GET / PUT / POST)
- 2
Load balancer → Service
forward
- 3
Service → Client
replay cached status and body
- 4
Service → Service
apply method semantics
- 5
Service → Client
200 / 201 / 204
- 6
Client
timeout — retry same method, URL, body, key
- 7
Client → Service
same request
- 8
Service → Client
same observable outcome
Architecture choices
| Decision | Why | Cost |
|---|---|---|
| Stateless app + idempotency store | Horizontal scale; key → response | Extra hop; TTL vs retry window |
| PUT for upserts, POST for commands | Clients retry PUT with no extra header | Clients must mint or know ids |
| PATCH only with a written contract | Fine-grained updates | Docs burden; easy to ship a non-idempotent op |
| Key on POST only | Less noise | PUT races remain |
| Circuit breaker + jitter | Retry storms | Client library work |
| GET + ETag / If-None-Match | Safe retries, cheaper origin | Invalidation |
Intermediaries (CDNs, ALBs): RFC 9110 allows automatic retry of safe methods. Retrying POST at a proxy is incorrect unless the proxy knows the application is idempotent (it does not). Disable POST retry at the load balancer; let the client retry with a key.
In-memory router (run this)
PUT upsert is stable across retries. POST without a key duplicates. POST with a key replays. No Flask, no network.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Expected: two PUTs leave one user. Two unkeyed POSTs leave two orders. The keyed POST plus retry leaves one extra order (replayed: true on the second call). Two DELETEs leave the user gone.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Interview Q&A
What is the difference between safe and idempotent in HTTP?
Answer
Safe methods must not have the intended effect of changing server state (GET, HEAD, OPTIONS, TRACE). Idempotent methods may write, but N identical requests leave the same state as 1 (PUT, DELETE, and all safe methods). All safe methods are idempotent; PUT is the classic counterexample of idempotent-but-not-safe.
Why is PUT /resource/123 idempotent even though it writes?
Answer
The request names the resource and sends the whole representation. Applying that replace once or five times converges to the same stored document. Observable effect: that URL holds that body. Contrast POST, which allocates a new resource each success.
Is DELETE /item/1 still idempotent if the second call returns 404?
Answer
Yes, if the item stays deleted. Idempotence is about state, not about returning the same status code — though APIs often return 204 twice so clients can treat the response as stable. A 404 that means “never existed” vs “already deleted” is a product choice, not a spec break.
When is PATCH idempotent?
Answer
When the patch is a set to a constant or a replace of a path. Not when it appends or increments. Write it in the OpenAPI description. If you cannot swear to it, require a key or use PUT.
Can a reverse proxy retry POST for you?
Answer
Not by the spec, and not in production. POST is not idempotent. Automatic POST retry at an ALB is a double-charge factory. Retry GET freely (with jitter). Retry PUT/DELETE if the body and URL are unchanged. Retry POST only in the client, with the same Idempotency-Key.
Why do we still send Idempotency-Key on some PUTs?
Answer
Concurrent PUTs with different bodies race: last write wins, which may not be the caller’s intent. A key (or If-Match ETag) binds the first accepted representation. Timeouts of the same body do not need a key for correctness, but a key still helps 409 in-flight.
Does Stripe send keys on GET and DELETE?
Answer
Stripe documents that idempotency keys have no effect on GET and DELETE for API v1 — those methods are already idempotent. Putting a key on GET is noise. Save the header for POST (and PATCH/PUT when you need the extra layer).
How do safe methods interact with caches?
Answer
GET/HEAD may be stored and reused. That is why they must not mint orders. Cache-Control, ETag, and If-None-Match make retries cheap: 304 is a successful safe retry. A “GET that bills” is a cache-amplified money leak.
POST /charge vs PUT /charges/{id} — which should a payments API use?
Answer
Product APIs almost always POST to a collection because the client does not know the charge id yet. Then you must implement keys + fingerprint. PUT is viable if the client generates ULIDs and you treat the URL as the intent — rare for cards, common for object storage.
Are OPTIONS and TRACE worth implementing?
Answer
OPTIONS is required for CORS. TRACE is an XST footgun and is usually disabled at the edge. Both are safe/idempotent in the RFC table; TRACE is not a feature you want in a public API.
Pitfalls
Write five routes for a bookstore: list books, get book, create order, replace cart, cancel order. Mark each S (safe), I (idempotent), K (needs Idempotency-Key). If create order is POST /orders, K is mandatory. If cancel is DELETE /orders/:id, I is enough. Then add two tabs hitting PUT /carts/me with different bodies at the same time — that is where K or If-Match appears.