Skip to main content
networking

HTTP idempotency & retries

12 min read
Fully authored

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).

MethodSafeIdempotentCacheableNote
GETRead only. Retry N times, same result.
HEADSame as GET but no body. Metadata check.
OPTIONSCORS preflight. Safe by design.
PUTFull replacement. Same body → same state N times.
DELETEDelete-of-already-deleted = 404 or 204. Both idempotent.
POSTDefault: 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:

The bug — no idempotency key
Alice ➜ Client library ➜ Merchant API ➜ Bank
t=0ms:
Client: POST /charges {amount: 100}
t=50ms:
Merchant → Bank: debit 100
t=800ms:
Bank confirms: OK ✓ (charge #A completed)
t=800ms:
Merchant → Client: 200 OK
t=805ms:
💥 Wi-Fi drop. Response lost.
t=1000ms:
Client timeout → retry the same POST
t=1050ms:
Merchant → Bank: debit 100 (as if new request)
t=1800ms:
Bank confirms: OK ✓ (charge #B completed)
t=1800ms:
Merchant → Client: 200 OK
🚨 Alice charged $200 for a $100 purchase.

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:

  1. Extracts the key from headers.
  2. Looks it up in a dedupe store (typically Redis, sometimes DynamoDB or Postgres).
  3. If found + status is COMPLETE — returns the cached response body. Does NOT re-execute the operation.
  4. If found + status is IN_PROGRESS — returns 409 Conflict (concurrent retry from another connection).
  5. If not found — marks IN_PROGRESS with the request fingerprint, executes the operation, stores the response, marks COMPLETE.
  6. TTL on the store entry: 24 hours is Stripe's convention. Long enough for retries + human investigation, short enough that Redis doesn't balloon.
The fix — with Idempotency-Key
Alice ➜ Client library ➜ Merchant API ➜ Redis ➜ Bank
t=0ms:
Client: POST /charges
Idempotency-Key: 7f3a2c1e-9b4d-4e5a-8f2b
t=1ms:
Merchant → Redis: GET dedupe:7f3a2c1e...
→ nil (not found)
t=2ms:
Merchant → Redis: SET dedupe:7f3a2c1e... = "IN_PROGRESS"
EX 86400 NX
t=50ms:
Merchant → Bank: debit 100
t=800ms:
Bank: OK ✓ (charge #A)
t=801ms:
Merchant → Redis: SET dedupe:7f3a2c1e... = "COMPLETE"
response = {status: "succeeded", charge_id: "A"}
EX 86400
t=805ms:
Merchant → Client: 200 OK
t=810ms:
💥 Wi-Fi drop
t=1000ms:
Client retries with SAME key 7f3a2c1e...
t=1001ms:
Merchant → Redis: GET dedupe:7f3a2c1e...
→ "COMPLETE" + cached response
t=1002ms:
Merchant → Client: 200 OK (from cache, NOT re-charged)
✓ Alice charged $100 exactly once. Retry safe.

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

Pitfall

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.

Pitfall

No TTL on the dedupe store

Redis fills forever. Choose 24h (Stripe convention) or 48h (extra safety for cross-day retries). Never "forever".

Pitfall

Not checking request-body fingerprint

Same key + different body silently returns wrong response. Data corruption at scale. Always hash-and-compare.

Pitfall

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).

Pitfall

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.

Pitfall

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

Stripe

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.

Shopify

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.

OpenAI

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.

AWS SDK

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.

Kafka

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.

Temporal / Cadence

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

  1. 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.
  2. Networks are unreliable; retries are inevitable. Idempotency turns retry-on-failure from a client-side hack into a first-class API property.
  3. 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.
  4. Enforce at multiple layers: API layer (Redis check), DB layer (uniqueness constraint on key). Belt-and-suspenders for concurrent-retry races.
  5. 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.