HTTP idempotency & retries
How idempotency-keys turn unreliable networks into safe retries — Stripe, Shopify, OpenAI patterns.
In 2011, Stripe had a problem. A merchant would call their POST /charges endpoint, the request would take 1.2 seconds because a bank check was slow, and their client library would time out at 1 second and retry. Sometimes the merchant would end up charging the customer twice. Sometimes they'd get a 500 Internal Server Error even though the charge went through — and retry, again causing a double charge. This is not a weird edge case. It is every API call that involves money and networks.
Stripe's answer, published in 2015, was a pattern that had lived in academic databases since the 1980s but had never crossed cleanly into HTTP APIs: idempotency keys. The client attaches a unique token to every mutating request. If the server sees the same token twice, it returns the original response instead of performing the operation again. Retries are safe. Money doesn't double. Networks can fail. Correctness is preserved.
The pattern is now industry standard. Shopify requires idempotency on all cart-mutating endpoints. PayPal, Adyen, and Square follow the same pattern. OpenAI added it to their API in 2023 for expensive LLM calls. AWS SDK auto-generates idempotency tokens for many services. The core idea: network operations are inherently unreliable — the API contract should make retries safe. If you build any API that mutates state, you will end up implementing this. Better to understand it deeply than to reinvent it badly.
Seminal references
- Fielding (2000) — REST dissertation §5.2.1 defines idempotent methods.
- RFC 9110 §9.2.2 — codified definition of idempotency for HTTP methods.
- Stripe API docs (2015) — the industry-standard implementation pattern. stripe.com/docs/api/idempotent_requests
- Kleppmann (2017) — Designing Data-Intensive Applications ch. 12 discusses at-least-once + idempotency.
- Gray & Reuter (1993) — Transaction Processing ch. 4 on retry safety in distributed systems.
The formal definition
An operation is idempotent if applying it N times has the same effect as applying it once. In HTTP, this means: sending the same request 1 time or 10 times leaves the server in the same state and returns the same response.
Idempotency is NOT the same as safety. A safe method has no side effects at all (GET, HEAD). An idempotent method may have side effects, but repeating them doesn't stack (PUT, DELETE). A non-idempotent method stacks side effects on repeat (POST creates a new row per call).
| Method | Safe | Idempotent | Cacheable | Note |
|---|---|---|---|---|
| GET | ✅ | ✅ | ✅ | Read only. Retry N times, same result. |
| HEAD | ✅ | ✅ | ✅ | Same as GET but no body. Metadata check. |
| OPTIONS | ✅ | ✅ | ❌ | CORS preflight. Safe by design. |
| PUT | ❌ | ✅ | ❌ | Full replacement. Same body → same state N times. |
| DELETE | ❌ | ✅ | ❌ | Delete-of-already-deleted = 404 or 204. Both idempotent. |
| POST | ❌ | ❌ | ❌ | Default: creates new resource per call. Add key to make idempotent. |
| PATCH | ❌ | ⚠️ | ❌ | Depends. JSON Patch (RFC 6902) yes; Merge Patch or JSONPatch-with-inc: no. |
The classic bug — what happens WITHOUT idempotency keys
Before we build the solution, feel the disaster. Alice hits "pay $100" on a merchant site. The request travels through her flaky home Wi-Fi. Watch what happens:
Alice was charged $200 for a $100 purchase. The merchant server did nothing wrong. The client library did nothing wrong (retrying on network error is the correct behavior). The bank did nothing wrong. But the composition failed — because the API had no way to tell "is this a first attempt or a retry?"
The fix — idempotency keys
The client generates a unique token (UUID, random 24-byte string, or timestamp+hash) and sends it in the Idempotency-Key header. The server:
- Extracts the key from headers.
- Looks it up in a dedupe store (typically Redis, sometimes DynamoDB or Postgres).
- If found + status is COMPLETE — returns the cached response body. Does NOT re-execute the operation.
- If found + status is IN_PROGRESS — returns 409 Conflict (concurrent retry from another connection).
- If not found — marks IN_PROGRESS with the request fingerprint, executes the operation, stores the response, marks COMPLETE.
- TTL on the store entry: 24 hours is Stripe's convention. Long enough for retries + human investigation, short enough that Redis doesn't balloon.
Idempotency-Key: 7f3a2c1e-9b4d-4e5a-8f2b
→ nil (not found)
EX 86400 NX
response = {status: "succeeded", charge_id: "A"}
EX 86400
→ "COMPLETE" + cached response
The Redis dedupe state machine
The dedupe store entry moves through a small state machine. Understanding this is the difference between an idempotency layer that works and one that has subtle races.
┌────────────┐
│ ABSENT │ (key never seen)
└──────┬─────┘
│ 1. First request: SET NX + TTL 24h
▼
┌────────────┐
│ IN_PROGRESS│ (executing operation)
└──────┬─────┘
│ Success?
│ ┌── yes → SET COMPLETE + response body
│ └── no → SET FAILED + error body
▼
┌────────────┐ ┌────────────┐
│ COMPLETE │ │ FAILED │
└──────┬─────┘ └──────┬─────┘
│ │
│ Retry with same key: return cached body
│ (any state below returns cached, not re-execute)
│ │
└──────────┬───────────┘
▼
┌────────────┐
│ EXPIRED │ (after 24h TTL)
│ │ Next retry treated as
│ │ new request. Rare in
│ │ practice.
└────────────┘
Concurrent retry while IN_PROGRESS?
→ return 409 Conflict (client should wait + retry)
The request fingerprint — a subtle but critical detail
Same key + same body → return cached response.
Same key + DIFFERENT body → reject with 422 Unprocessable Entity.
Why? Because if a client sends POST /charges {amount: 100} with key abc123, then follows up with POST /charges {amount: 200} also with key abc123, one of two things is happening:
- Client bug — they reused the key. Silently returning the $100 charge response for a $200 request would cause a data-integrity nightmare.
- Attack — someone stole a client's key from logs and is trying to piggyback different requests on it. Rejecting protects the client.
Either way, reject with a clear error so the bug surfaces at development time rather than silently corrupting production. Stripe does this. Shopify does this. Any serious implementation does this.
Common implementation pitfalls
Using a client-supplied ID as the dedupe key without validation
Attacker can DoS by flooding your Redis with millions of distinct keys. Mitigation: cap key length (Stripe: 255 chars), require alphanumeric+hyphen, rate-limit by caller.
No TTL on the dedupe store
Redis fills forever. Choose 24h (Stripe convention) or 48h (extra safety for cross-day retries). Never "forever".
Not checking request-body fingerprint
Same key + different body silently returns wrong response. Data corruption at scale. Always hash-and-compare.
Enforcing idempotency at API layer but not DB layer
Race condition: two retries slip through Redis check simultaneously. Fix: DB uniqueness constraint on the key too (belt-and-suspenders).
Treating PATCH as always idempotent
JSON Patch (RFC 6902) is idempotent. JSON Merge Patch (RFC 7396) is not. PATCH-with-increment (like {counter: {$inc: 1}} ) is definitely not. Design your PATCH semantics deliberately.
Skipping idempotency on "read" endpoints
GET is idempotent by spec, so no key needed. But "read that mutates a counter" (e.g., increment-then- return) is a POST in disguise — apply idempotency there too.
Idempotency in distributed transactions (sagas)
In a saga pattern, a multi-step business transaction is decomposed into local transactions per service. Each step MUST be idempotent because the orchestrator retries on failure. Same for compensating actions: cancel-order called twice = still cancelled.
This is why Temporal, AWS Step Functions, and Cadence workflow engines require every activity to be idempotent — they replay activities on worker restarts and cannot afford double-execution. The pattern from your HTTP API layer is the same pattern that makes distributed workflows correct.
Real-world implementations — same pattern, different scales
The pattern-defining implementation
24h TTL on Redis. Idempotency-Key header is opaque. Same-key-different-body returns 400. Response includes Idempotent-Replayed: true so clients can distinguish fresh from cached responses. Stored keys can be introspected via the Stripe dashboard for audit trails.
Cart-mutating endpoints
All checkout POSTs require Idempotency-Key. Stored in Postgres (they use their MySQL/Vitess for cart, Postgres for durability of the key store). 24h retention. Uses request-body SHA-256 fingerprint.
LLM call dedup (2023)
Long-tail cost problem: retry of a 5000-token GPT-4 call costs $0.30 wasted if silently deduped. OpenAI added Idempotency-Key on all completions endpoints in 2023. Server returns the same completion (deterministic if temperature=0). Prevents double-billing on retry storms.
Auto-generated tokens
Boto3 auto-attaches ClientRequestToken parameter to many mutating APIs (CloudFormation CreateStack, EC2 RunInstances, DynamoDB TransactWriteItems). Same behavior as Stripe pattern, just in a query param instead of header. Retried by SDK on ThrottlingException.
Producer idempotence
Enabled by default since Kafka 3.0. Producer assigns each message a (producer_id, sequence_number) tuple. Broker dedupes retries within a session. Combined with transactions, gives you exactly-once semantics for streaming pipelines. Config: enable.idempotence=true.
Workflow activities
Every activity function MUST be idempotent because the worker replay mechanism re-runs already-executed activities during recovery. Explicit rule in the framework: "your activity function will be called again with the same input; make sure that's ok."
Key takeaways
- Idempotency is a property of the API contract, not the implementation. A GET is idempotent because HTTP says so. A POST is idempotent only if the server treats idempotency-keys properly.
- Networks are unreliable; retries are inevitable. Idempotency turns retry-on-failure from a client-side hack into a first-class API property.
- The pattern is uniform: client generates key, server dedupes via Redis with 24h TTL and fingerprint check. Same key + same body → cached response. Same key + different body → reject.
- Enforce at multiple layers: API layer (Redis check), DB layer (uniqueness constraint on key). Belt-and-suspenders for concurrent-retry races.
- Distributed workflows depend on it: Temporal, Step Functions, Cadence, Kafka producers all require activity idempotence for their correctness guarantees.
Practice what you just read
Every foundation concept has a companion quiz to close the loop.