Backend / REST APIs / 01_idempotency.md

Idempotency

Updated 5 interview angles 4 min read source
On this page4
  1. What the methods promise
  2. Making POST retryable
  3. Where else it shows up
  4. Interview angle

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:

http
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:

python
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 result

Four details make this correct rather than approximately correct:

  • The unique constraint does the work. ON CONFLICT DO NOTHING is atomic; a SELECT then INSERT has 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 409 and 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 — DELETE returning 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?” - SELECT then INSERT lets two concurrent retries both pass the check. Use a unique constraint with ON CONFLICT DO NOTHING so 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.