Networks fail in the one way that makes idempotency non-optional: a client sends a request, the server processes it and crashes before the response arrives, and the client — having no idea whether the request landed — retries. Without idempotency, that retry double-charges a card or double-ships an order. With it, the retry is a no-op.
Idempotency Keys
The standard mechanism is a client-generated unique key attached to the request, which the server uses to recognize a retry as the same logical operation rather than a new one.
def charge_card(request):
idempotency_key = request.headers["Idempotency-Key"]
existing = db.idempotency_records.find(idempotency_key)
if existing:
return existing.response # replay, don't reprocess
result = payment_gateway.charge(request.amount, request.card)
db.idempotency_records.insert(idempotency_key, result, ttl_days=7)
return resultThe key has to be generated by the client, not the server — a server-generated key on each retry would just create a new record every time, defeating the point. UUIDs generated once per logical user action (not per HTTP attempt) are the usual choice.
The Race Between Check and Write
The naive version above has a bug: two concurrent retries of the same request can both pass the find before either finishes the insert, and both go on to charge the card. The fix is to make the check-and-reserve atomic — a unique constraint on the idempotency key column, with the second insert failing and the failing request waiting for or reading the first one's result, rather than a separate read-then-write.
INSERT INTO idempotency_records (key, status)
VALUES ($1, 'in_progress')
ON CONFLICT (key) DO NOTHING
RETURNING key;
-- if no row returned, another request already owns this keyIdempotency Is Not Automatic From HTTP Verbs
A common misconception is that PUT is idempotent and POST isn't, full stop, and that's the end of the analysis. PUT /accounts/1/balance with {"balance": 500} is idempotent by the spec's definition (same request, same end state) but is almost never what you want for a financial operation — two racing clients both trying to set balance to different values will silently clobber each other. What you actually want is often expressed as a POST with an idempotency key: POST /accounts/1/transactions with {"amount": -50, "idempotency_key": "..."} is semantically an append, safe to retry, and doesn't have the last-writer-wins problem PUT does.
Where Else It Matters
- Message consumers: at-least-once delivery (SQS, Kafka with default settings) means every consumer has to treat redelivery as normal, not exceptional — the same key-and-check pattern applies to message processing, not just HTTP.
- Downstream calls inside a saga step: if a step itself calls an external API, that call needs its own idempotency key, independent of the saga's — a saga retry shouldn't fan out into duplicate calls to a third party.
Idempotency isn't a property you add at the edge once and forget — it has to be threaded through every hop that might retry, because the guarantee is only as strong as its weakest link.