Idempotency
An operation is idempotent if performing it twice leaves the same server state as performing it once. It matters because a timeout tells the client nothing — the request may have succeeded, failed, or be still running — and without idempotency the only safe response to that is to give up.
REST’s other constraints are in REST principles; statelessness in Stateful vs Stateless (REST and beyond).
What the methods promise
| Method | Idempotent | Safe |
|---|---|---|
| GET, HEAD | yes | yes |
| PUT | yes | no |
| DELETE | yes | no |
| POST | no | no |
| PATCH | not by default | no |
These are contracts you uphold, not properties you get. A PUT handler that
appends rather than replaces has broken the contract, and every proxy, client
library and retry policy in the chain is now wrong about your API.
The distinction people miss: idempotent means the state is the same, not the
response. DELETE /orders/42 returning 204 then 404 is fully idempotent — the
order is gone either way.
PATCH depends entirely on the body. {"op": "set", "status": "paid"} is
idempotent; {"op": "increment", "by": 1} is not.
Making POST retryable
The client generates a key per logical operation and sends it with every attempt of that operation:
POST /payments HTTP/1.1
Idempotency-Key: 9f8c2b41-0e7a-4d3f-a1e2-77b0d5c9a3ef
Content-Type: application/json
{"amount": 4200, "currency": "GBP"}The server stores the key with the result, and a repeat returns the stored response instead of charging again:
async def create_payment(key: str, body: PaymentIn, conn):
async with conn.transaction():
row = await conn.fetchrow(
"INSERT INTO idempotency (key, state)"
" VALUES ($1, 'running')"
" ON CONFLICT (key) DO NOTHING RETURNING id",
key,
)
# someone got there first
if row is None:
return await replay(key, conn)
result = await charge(body)
await conn.execute(
"UPDATE idempotency SET state='done', response=$2"
" WHERE key=$1", key, dumps(result),
)
return resultFour details make this correct rather than approximately correct:
- The unique constraint does the work.
ON CONFLICT DO NOTHINGis atomic; aSELECTthenINSERThas a race that two concurrent retries will find. - One transaction covers the key and the effect. If they commit separately, a crash between them either double-charges or loses the record.
- The stored response is returned verbatim, so the client sees the same body and the same resource id it would have seen the first time.
- The key must be stable across retries. Generating it inside the retry loop defeats the entire mechanism, and this is the common implementation bug.
Gotcha: a concurrent retry arriving while the first is still running is the case people forget. Return
409and let the client retry shortly, or block on the row — but do not fall through and charge twice.
Keys need a retention policy — long enough to cover any client’s retry window, typically 24 hours, then expired.
Where else it shows up
The same requirement appears in every at-least-once delivery system: a message broker redelivers after a consumer crash, so consumers must be idempotent or deduplicate on a message id. See Message queues.
And in outbound calls — a retried POST to a payment provider can charge twice,
which is why the retry policy in
Writing a client for an external API
excludes non-idempotent methods unless the provider honours a key.
Interview angle 5
- “Which HTTP methods are idempotent?” - GET, HEAD, PUT and DELETE by definition; POST is not, and PATCH depends on the body. Idempotent means repeating the request leaves the same server state, not that the response is identical —
DELETEreturning 204 then 404 is still idempotent. - “How do you make POST safely retryable?” - a client-supplied idempotency key stored with the result. On a repeat, return the original response rather than performing the action again. The key must be stable across retries and written in the same transaction as the effect.
- “What’s the race condition in that?” -
SELECTthenINSERTlets two concurrent retries both pass the check. Use a unique constraint withON CONFLICT DO NOTHINGso the database decides, and handle the in-flight case explicitly rather than falling through. - “Why does this matter more than it looks?” - a timeout is precisely the case where the client doesn’t know whether the operation applied. Without idempotency, the safe choice is not to retry, which means abandoning a request that probably succeeded.
- “Where else does this apply?” - any at-least-once delivery. A broker redelivers after a consumer crash, so the consumer must be idempotent or deduplicate on a message id; the alternative is duplicated side effects on every restart.