Retries, idempotency keys and concurrency

Topics in this section
2 cards

Idempotency keys: safe retries for POST

Networks fail in the worst place: the server created the payment, but the response never reached the client. The client can't tell 'failed' from 'succeeded, answer lost', so it retries, and a plain POST charges twice.

An idempotency key fixes this. The client generates a unique key (a UUID) per logical operation and sends it in a header, Idempotency-Key. The server stores the key with the result of the first request. A retry with the same key gets the stored response instead of running the operation again. If the same key arrives with a different body, the server rejects it (commonly 422), because that's a client bug, not a retry. Keys are kept for a limited time, such as 24 hours.

A POST with an idempotency key times out; the retry with the same key returns the stored result instead of charging again
In real life

Stripe accepts an Idempotency-Key header on POST requests and returns the saved result for repeats. An IETF draft proposes the same header for any HTTP API.

In code: HTTP · Python
POST /payments
Idempotency-Key: 8e0f3c1a-6b0e-4a8e-9f6c-2d1b7a1c9e55   # Stripe-style
{"order_id": "42", "amount": 1999, "currency": "EUR"}
→ (timeout: the client never sees the answer)

POST /payments                      # retry, same key, same body
Idempotency-Key: 8e0f3c1a-6b0e-4a8e-9f6c-2d1b7a1c9e55
→ 201 Created                        # the stored response; no second charge
  • In production the key is saved in the same database transaction as the payment, so a crash can't leave one without the other.
  • Two requests with the same key arriving at the same moment must not both run: lock on the key or rely on a unique constraint.
Check yourself

A client retries POST /payments with the same Idempotency-Key after a timeout. What should the server do? (Choose two.)

Select 2, then check.

Lost updates, ETags and If-Match

Two people open the same order. Ann changes the address, Bo changes the delivery date, both save with PUT. Bo's save, built from the old version, silently overwrites Ann's change: a lost update.

HTTP's fix is optimistic concurrency. Every response carries an ETag, a version tag of the resource. The client sends it back in If-Match when it writes. If the resource changed since, the tags differ and the server answers 412 Precondition Failed instead of overwriting; the client reloads and tries again. An API can require this by answering 428 Precondition Required to writes without If-Match.

For conflicts with business state rather than versions, like booking a seat that's already taken, the answer is 409 Conflict.

In real life

Booking a hotel room: two guests try the last room at the same time. The database's unique constraint lets one insert win; the other request gets 409 Conflict with 'room no longer available'.

In code: HTTP
GET /orders/42
→ 200 OK
ETag: "v7"

PUT /orders/42
If-Match: "v7"
{ … }
→ 200 OK
ETag: "v8"

PUT /orders/42          # someone else, still holding v7
If-Match: "v7"
→ 412 Precondition Failed
  • 412 and 428 are defined in RFC 9110 and RFC 6585.
  • The ETag can be a version number, an updated_at timestamp, or a hash of the content.
Check yourself

An admin panel must stop two editors from overwriting each other's changes to the same product. Which pieces belong in the design? (Choose three.)

Select 3, then check.