Reference · Lesson 4

Idempotency keys

Making a non-idempotent operation safe to retry. One page, for quick lookup.

The rule

The key names the operation, not the attempt. Generate it once when the user starts the action, persist it, and send it on every retry. The server claims it atomically and replays the first answer.

Idempotent or not

idempotent: f(x) = f(f(x))not idempotent
add to a set · set a field to a value · PUT · DELETE · GETincrement a counter · append · charge · send an email · POST

RFC 9110 §9.2.2: a client "SHOULD NOT automatically retry a request with a non-idempotent method unless it has some means to know that the request semantics are actually idempotent". The key is that means.

The table (lib/db.ts)

CREATE TABLE idempotency_keys (
  key          TEXT PRIMARY KEY,   -- the atomic claim
  fingerprint  TEXT NOT NULL,      -- fingerprint(params), as is or hashed
  status       TEXT NOT NULL,      -- 'in_flight' | 'done'
  response     TEXT,               -- first answer, replayed
  locked_at    INTEGER NOT NULL    -- old lock = worker probably died; also YOUR fencing token
);

The decision, in order

state when the request arrivesrun() doesstatus
no row → INSERT … ON CONFLICT(key) DO NOTHING wins (.changes === 1)run work · ok → save done + response (fenced) · error → delete our claim (fenced), rethrowresult
row exists, fingerprint differsreject; never run, never replay422 MismatchError
done, same paramsreplay saved response; work doesn't runoriginal result
in_flight, lock younger than lockMsreject; client retries later; nothing saved409 ConflictError
in_flight, lock staletake over with compare-and-set on locked_at (one winner), run workresult

Fencing: writes after the claim

writeonly if0 rows changed means
save the responsestatus = 'in_flight' AND locked_at = <mine>someone took over: throw ConflictError
release on errorstatus = 'in_flight' AND locked_at = <mine>the claim isn't mine any more: leave it

lockMs must outlast any honest run of the work (≥ the request deadline + the downstream timeout), or you take over live workers by design. Kleppmann: "include a fencing token with every write request".

Classic bugs

bugeffectfix
new key per attempteach retry is a new operation (lab: 24/100 double-charged, same as no key)one key per operation, persisted before the first send
SELECT, then INSERTtwo concurrent requests both see "none", both act (can't happen in the lab's single synchronous process, so no test catches it; it does in production)claim with a single atomic INSERT on a unique key
unfenced save or deletea slow worker overwrites the new owner's answer, or deletes its claimcondition every write on your locked_at
key only at the top hopcrash after the charge, before saving → re-run charges againpass your own key downstream (charge:<orderId>)
replay on different paramshides a client bug, returns the wrong answercompare fingerprints, 422
retention shorter than retry windowpruned key → retry becomes a new chargekeep keys longer than any client retries (Stripe v1: prunable after ≥24 h)

Lab numbers (npm run l4, seed 1)

  version       charges   double-charged orders
  no-key            132                      24
  key-per-try       132                      24
  same-key          100                       0

The two error codes

errormeansclient should
ConflictError · 409key still in flightretry later, same key (retryable)
MismatchError · 422key reused with different paramsnever retry: a client bug

Design note: save errors, or release the claim?

Stripe's API v1 saves the status code and body of the first request "including 500 errors", so a retry gets the same error back. This lab's store does the opposite: if the work throws, it deletes its claim so a retry can run the work again. That is safe only because the work is idempotent downstream (the PayCo key charge:<orderId>). If re-running the work could double-charge, releasing the claim would bring the Lesson 1 bug back. Pick one deliberately.

Limits in a real system

Vocabulary

termmeaning
idempotentDoing it twice has the same effect as once.
idempotency keyClient-chosen unique ID for one logical operation, sent on every retry.
fingerprintA string computed from the request params (stable JSON, or a hash of it), stored with the key to catch reuse with different data.
fencing tokenA value proving you still hold a lock; every later write checks it.
atomicHappens completely or not at all; no other request can see it half-done.
compare-and-setUPDATE … WHERE col = <old value>: succeeds only if nobody changed the row since you read it.
claimAtomically recording "I'm working on this key"; only one request wins.
stale lockAn in_flight claim older than lockMs: its worker probably died.
foreign state mutationA change in another system (a charge) that your database can't roll back.

Sources