3 Ways to Make Your API Requests Idempotent (With Working Code)
We lost a customer payment last year because of a retry. A third-party gateway timed out on our first charge attempt, so our client retried — and the customer got billed twice. It wasn't a huge amount, but the refund process, the support ticket, and the customer's trust took weeks to repair.
The root cause wasn't the gateway. It was us: our endpoint wasn't idempotent. We assumed every request happens exactly once. In a world of flaky networks, that assumption is wrong.
Here's what I learned, and the three patterns I now reach for whenever a request can be retried.
Why "retry" and "exactly once" don't mix
Networks fail in ways that make it impossible to know whether a request reached the server:
- The server processed the request, but the response got lost on the way back.
- The server is still processing when the client's timeout fires.
- A load balancer retried the request internally without telling you.
When the client retries, the server may process the same logical operation twice. For a GET, that's usually harmless. For a POST /payments, it's a double charge.
The general fix is to make operations idempotent: performing the same operation multiple times has the same effect as performing it once.
Pattern 1: Idempotency keys
The most common pattern is an idempotency key — a client-generated unique identifier that the server uses to deduplicate retries.
import uuid
# In-memory store for this example. Use Redis or Postgres in production.
_store = {}
def process_payment(payment_id, amount, idempotency_key):
if idempotency_key in _store:
return _store[idempotency_key] # replay the cached result
# ... actually charge the card here ...
result = {"payment_id": payment_id, "status": "succeeded"}
_store[idempotency_key] = result
return result
The client generates the key once per logical operation and reuses it on every retry:
key = uuid.uuid4().hex # generated once, kept across retries
process_payment("pmt_123", 49.99, key) # first attempt
process_payment("pmt_123", 49.99, key) # retry — returns the cached result
The server stores the key-to-response mapping. On a duplicate, it returns the stored result instead of re-executing the work.
A few details I got wrong the first time:
- The key must be generated by the client, not the server. The whole point is that the server can't distinguish a new request from a retry without it.
- Guard against races. Two retries can arrive at the same time. A naive "check then insert" has a gap where both threads see the key as missing. Use an atomic insert (or a lock) so only one side effect happens.
- Return the same response, not just a success flag. If the first attempt returned a 201 with a payment ID, the retry should return that same 201 and ID — not a 409 conflict or an empty 200.
Pattern 2: Make the operation naturally idempotent
Some operations don't need a key if you design the semantics to be idempotent from the start.
PUT is the classic example. A PUT /users/42 that sets the full representation is idempotent by definition — sending it twice sets the same state twice.
For "create" operations, derive a stable identifier from the request itself instead of generating a random ID on every call:
import hashlib
def create_user(email, name):
user_id = "user_" + hashlib.sha256(email.encode()).hexdigest()[:16]
# INSERT ... ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name
return {"id": user_id, "email": email, "name": name}
Because the ID is a deterministic hash of the email, sending the same request twice produces the same user — no duplicate rows. This is a form of upsert, and it works well for records with a natural unique key.
The tradeoff: you need a natural key (email, SKU, external ID). If you don't have one, fall back to idempotency keys.
Pattern 3: Optimistic concurrency
Idempotency keys protect against retries. But what about two different requests that both want to modify the same record? That's where optimistic concurrency helps — and it pairs well with the first two patterns.
Send a version (or updated_at timestamp) with the request, and reject the update if the version doesn't match:
def update_article(article_id, body, expected_version):
row = db.get(article_id)
if row["version"] != expected_version:
return {"error": "conflict", "current_version": row["version"]}, 409
db.update(article_id, body=body, version=row["version"] + 1)
return {"version": row["version"] + 1}, 200
This doesn't make a single request idempotent on its own, but it prevents a common source of duplicate side effects: two writers clobbering each other. Combined with idempotency keys, it covers most of the failure modes I've actually hit in production.
The pitfalls that will bite you
A few things I learned the hard way:
- Expire your idempotency records. Don't store keys forever. Keep them for 24 hours — that covers virtually all realistic retry windows — then delete them. Otherwise you've built a slow memory leak.
-
Scope keys per endpoint and per user. The same key on a different endpoint should not collide. Namespace it:
user123:payments:abc.... - Handle the "stale key" case. If a client reuses a key for a different payload, that's a bug. Detect it and return a 422 instead of silently returning the old result.
- Test with real concurrency. Fire 50 simultaneous requests with the same key and confirm only one side effect happens. A unit test that calls the function twice sequentially will miss the race.
What I'd tell my past self
The whole idempotency-key idea felt like over-engineering until the double charge happened. Now I treat it like input validation: something you add by default to any endpoint that has a side effect.
If your API mutates state and a client might retry, add an idempotency key. It's maybe 30 lines of code. The alternative is a support ticket titled "you charged me twice."
Top comments (0)