Lesson 9 / 27

Idempotency Keys

Make POST requests safe to retry with a client-supplied idempotency key.

The double-charge problem

A client sends POST /payments, the connection drops, and it does not know if the payment happened. Retrying blindly could charge twice. An idempotency key solves it: the client generates a unique value (a UUID) for the intent and sends it in an Idempotency-Key header. The server stores the result under that key; if the same key arrives again it returns the stored result instead of repeating the action, usually with a header marking it as a replay. Keys should expire after a period (for example 24 hours), and reusing a key with a different request body should be rejected. Make such keys required for operations with real side effects: payments, orders, emails.

Safe retries, cheap repeats

Networks fail, so well-designed APIs make retries safe and repeated reads cheap.

Four tools: idempotency, caching, limits, async.
Figure 3.1 — Idempotency, caching, limits and async.

A replayed payment, run

I ran this against a small real HTTP API built with only the Python standard library (full code in the case study). The first POST with key abc-123 creates pay_1 (201, with a Location header). The identical retry returns the same payment with 200 and Idempotent-Replayed: true, and b1 == b2 is True. A missing key gives 400, and an invalid amount gives 422 with field details.

# uses srv, base and call() from the runnable demo in the case study
k = {"Idempotency-Key": "abc-123", "Content-Type": "application/json"}
s1, h1, b1 = call("POST", "/v1/payments", {"amount": 500}, k); s2, h2, b2 = call("POST", "/v1/payments", {"amount": 500}, k)
print(s1, b1, h1.get("Location")); print(s2, b2, h2.get("Idempotent-Replayed"), b1 == b2)
print(call("POST", "/v1/payments", {"amount": 500}, {"Content-Type": "application/json"})[0], call("POST", "/v1/payments", {"amount": -5}, {**k, "Idempotency-Key": "z"})[2]["errors"])

Output:

201 {'id': 'pay_1', 'amount': 500, 'status': 'succeeded'} /v1/payments/pay_1
200 {'id': 'pay_1', 'amount': 500, 'status': 'succeeded'} true True
400 [{'field': 'amount', 'message': 'must be > 0'}]

Store the key and the result atomically

Two retries can arrive at the same time. Reserve the key before doing the work (for example with a unique database constraint) so only one request executes and the other waits or gets the stored result.

Quick check: What does a server do when it receives a repeated Idempotency-Key with the same request?

  • Deletes the first result
  • Performs the action again
  • Returns the stored result without repeating the action
  • Crashes
Answer

Returns the stored result without repeating the action — The key links retries to the original attempt so the side effect happens once.