Lesson 4 · Milestone: Idempotency

Idempotency keys

Lesson 3 taught you to retry gently. But a retry after a lost response is exactly what charged 22 customers twice in Lesson 1. This lesson makes the retry itself safe.

By the end you'll be able to make a non-idempotent operation, a charge, safe to retry with an idempotency key, including the hard cases: two copies arriving at the same moment, and your server crashing halfway through. You'll build it in labs/payflow/src/idempotency.ts.

idempotentDoing it twice has the same effect as doing it once.
idempotency keyA unique ID the client picks for one logical operation and sends with every retry of it.
fingerprintA string computed from the request's parameters (stable JSON, or a hash of it), stored with the key so reuse with different data is caught.
atomicHappens completely or not at all, and no other request can see it half-done.
claimRecording "I'm working on this key" atomically, so only one request can win.
fencing tokenA value that proves you still own a lock. Every later write checks it, so a worker that lost its lock can't overwrite the new owner.

Some operations are safe to repeat, some aren't

Kleppmann defines it in one line: a function f is idempotent if f(x) = f(f(x)). Incrementing a counter isn't. Adding an element to a set is[1]. His notes show a Twitter profile following a negative number of people, probably from a retried "unfollow", then add: "consider the act of deducting £1,000 from your bank account balance. The database operation is essentially the same"[1].

NOT IDEMPOTENT · counter likes = 0 +1 likes = 1 +1 likes = 2 a retry changed the answer IDEMPOTENT · set { } ∪ u7 { u7 } ∪ u7 { u7 } a retry is harmless
Kleppmann's example. A charge behaves like the counter: each one that arrives is a new charge.

HTTP itself says a client shouldn't automatically retry a non-idempotent request like POST "unless it has some means to know" that it's safe[2]. POST /checkout is exactly the request we need to retry, so we give it that means.

The idea: name the operation, not the attempt

The client generates a unique ID for the checkout (a random ID such as crypto.randomUUID(), as Stripe suggests[3]) and sends it with the request, and with every retry of that request. AWS calls it a "unique caller-provided client request identifier". The server remembers which IDs it has already handled, and for a repeat it must "deliver a semantically equivalent response", meaning the same answer as the first time, not an error like "already exists"[4]. Why not an error? Because the caller would then have to work out whether its own request created the thing. That's the unknown outcome from Lesson 1 again.

Mental model: a cloakroom ticket. You hand over your coat and get ticket #41. If you come back three times with #41, you get the same coat back three times; the attendant doesn't hang up three coats. The ticket names the coat, not your visit. The model breaks in two places, and those two places are this lesson's hard part. First, two people can walk up with ticket #41 at the same instant. Second, the attendant can faint halfway through hanging the coat up. The ticket alone doesn't tell you whether the coat made it onto a hook.

Watch one key absorb a lost response, a double click and a crash. Press ▶, use the chapters, or turn on 🔇 narration.

The server side: a table and five cases

The memory lives in your database, next to your orders. (In the diagrams, k1 is short for a real key such as client-key-o1.) lib/db.ts already creates the table:

CREATE TABLE idempotency_keys (
  key          TEXT PRIMARY KEY,          1
  fingerprint  TEXT NOT NULL,             2
  status       TEXT NOT NULL,             3  -- 'in_flight' | 'done'
  response     TEXT,                      4
  locked_at    INTEGER NOT NULL           5
);
  1. The primary key is the whole trick. The database guarantees that only one row per key can exist. So "insert the key" is an atomic claim: of two concurrent requests, exactly one insert succeeds.
  2. What was asked. A string computed from the parameters (fingerprint() sorts the keys, so {a,b} equals {b,a}). Store it as is or hash it; the tests only compare fingerprints your own store wrote. AWS returns "a validation error indicating a parameter mismatch" in that case[4].
  3. Where the work is. in_flight while someone is working on it, done once the response is saved.
  4. The first answer, as JSON, replayed to every retry.
  5. When the claim was made, and your fencing token. A very old in_flight lock means the worker probably died[5]. "Probably" matters: see fencing below.

Every request goes through one decision. This is the contract of IdempotencyStore.run(key, params, work):

INSERT key … ON CONFLICTDO NOTHING inserted already there claimed: run workok → save, status done error → delete our claim, rethrow fingerprint differs? → 422 Mismatch status done? → replay saved response lock fresh? → 409 Conflict, retry later lock stale → take over(compare-and-set locked_at), run work CHECKED TOP TO BOTTOM
Five cases, checked in order. Only the first and the last ever run the work.

Compare-and-set means an UPDATE … WHERE locked_at = <the old value>: it succeeds only if nobody changed the row since you read it, so of two retries that both see a stale lock, only one takes over.

The two error answers are the canonical meaning of these codes for the rest of the course:

ErrorMeansThe client should
ConflictError · 409this key is still being worked onretry later, with the same key. It's temporary.
MismatchError · 422this key was already used with different parametersnever retry. It's a client bug; fix the code.

That's why isRetryable in your Lesson 3 src/retry.ts treats ConflictError as retryable and MismatchError as not: the tests demanded it, and now you know the reason.

Why not "SELECT, then INSERT"?

The tempting version is: look the key up; if it isn't there, do the work and insert it. Together the steps have a gap, and a double click lands right in it:

CHECK, THEN ACT · two taps, same key tap Atap B SELECT k1: none SELECT k1: none charge charge 2 charges CLAIM FIRST INSERT checks and claims in ONE atomic step: B's insert finds A's row and B gets 409. No gap in between.
Both requests checked before either one wrote. The fix is to make the check and the write the same database operation.

AWS solves this by recording the token and the change it makes in one database operation: "An ACID server-side operation needs to be an 'all or nothing' or atomic process"[4]. That works when the change lives in your own database. Ours doesn't: the charge happens at PayCo, and no transaction of ours can include it. So we follow Brandur Leach's design for this case[5]: make the claim atomic, do the outside work, then save the answer. INSERT … ON CONFLICT(key) DO NOTHING, followed by checking .changes === 1, is the smallest atomic claim SQLite gives you.

An honest limit of this lab. node:sqlite is synchronous and the lab runs in one process, so nothing can run between your SELECT and your INSERT unless you await in between. A SELECT-then-INSERT claim, or a takeover without compare-and-set, therefore can't race here, and no test can catch it. In production, with several server processes sharing one database, both race exactly as the diagram shows. Write the atomic version anyway: it's the one that survives the move.

Keys must go all the way down

Now the faint attendant. Our shop claims k1, calls PayCo, PayCo charges, and then our process dies before saving the response. The row sits in_flight forever. After lockMs, a retry takes it over and runs the work again. That calls PayCo a second time.

The only thing that makes this safe is the step below us. The charge to PayCo is what Brandur Leach calls a foreign state mutation: "we've pushed data into a system beyond our own boundaries and we shouldn't lose track of it"[5]. Our database can't roll it back. So the shop sends its own idempotency key to PayCo, derived from the order, such as charge:o1. lib/provider.ts honours it like Stripe does: the same key returns the original charge, not a new one.

Clientretries key k1 Shopidempotency_keys charge:o1 PayCodedupes by key each hop guards against retries from the hop above it
The test "end to end: crash after charging" in test/04-idempotency.test.ts proves the second hop matters.

Fencing: when "probably died" is wrong

A stale lock doesn't prove the worker died. It may just have been slow: a long garbage-collection pause, a slow PayCo call. Then the old worker wakes up after a retry took over, and two things can go wrong. It saves its response over the new owner's, or, if its work failed, it deletes the new owner's claim and lets a third request in. Martin Kleppmann's fix is a fencing token: "you need to include a fencing token with every write request to the storage service", and the storage rejects writes carrying an older token[6].

In the lab, the token is the locked_at value you wrote, at the claim or at the takeover. Every later write checks it:

save:     UPDATE … SET status='done', response=?
          WHERE key=? AND status='in_flight' AND locked_at=<mine>
          0 rows changed → I lost the lock → throw ConflictError
release:  DELETE … WHERE key=? AND status='in_flight' AND locked_at=<mine>

Two tests in test:04 play the slow worker: one whose save comes too late, one whose work fails after a takeover. The fencing only helps if lockMs is longer than any honest run of the work, so set it above your whole deadline plus PayCo's timeout. Otherwise you take over live workers by design.

A design choice. If the work throws, our store deletes its claim so a retry can run the work again. That's only safe because the work is itself idempotent downstream (the charge:o1 key). Stripe chooses differently; the reference sheet compares the two.

What the lab shows

npm run l4 sends 100 checkouts from a phone to the shop over a network that loses 20% of responses. The phone makes up to 5 attempts. The shop's charge deliberately has no PayCo key here, so the only protection is the shop's own key handling:

  version       charges   double-charged orders   client gave up   calls
  no-key            132                      24                0     132
  key-per-try       132                      24                0     132
  same-key          100                      0                0     132

Look at the middle row. A fresh key on every attempt is exactly as bad as no key at all: each retry looks like a brand-new checkout. This is the most common idempotency bug in practice. Generate the key once, when the user starts the operation, and store it with the pending action so retries (even after an app restart) send it again.

Keys don't live forever: once a key is pruned, a reused key is a new request, so your retry window must be shorter than your retention window. Stripe keeps keys "for at least 24 hours" and says to "limit retries of an unresolved operation to the first 24 hours"; if the outcome is still unknown after that, "don't retry with either the old key or a new one", reconcile instead[7]. AWS ties retention to "the lifetime of the resource, plus an interval"[4].

Two client rules follow from the same Stripe page. On a 409, "wait and retry with that key and the same parameters. A new key can start a second operation." And to change the parameters after an unknown outcome (the user edits the cart after a timeout), "first establish that the original operation didn't succeed and isn't still executing", then use a new key[7].

Your turn: src/idempotency.ts

Implement IdempotencyStore.run, then run npm run test:04. The 12 tests cover each case in the decision diagram, a restart, the end-to-end crash and the two fencing cases. If node:sqlite is new to you, this is all of it you need:

const st = db.prepare('UPDATE t SET a = ? WHERE k = ?');  // compile once, with ? placeholders
st.run(1, 'k1').changes        // run a write: how many rows it changed (0 or 1 here)
db.prepare('SELECT * FROM t WHERE k = ?').get('k1')   // first row, or undefined
  as { a: number } | undefined                        // rows come back untyped: cast them

Before you start, fill in the core below from memory. It's the claim and the first three checks; the take-over and the release on error are yours to write.

const claimed = this.db.prepare(`
  INSERT INTO idempotency_keys
    (key, fingerprint, status, locked_at)
  VALUES (?, ?, 'in_flight', ?)
  ON CONFLICT(key) DO `)
  .run(key, fp, now). === 1;

if (!claimed) {
  const row = /* SELECT … WHERE key = ? */;
  if (row.fingerprint !== fp)                       // HTTP 
    throw new MismatchError();
  if (row.status === '')
    return JSON.parse(row.response);
  if (now - row.locked_at < this.lockMs)            // HTTP 
    throw new ConflictError();
  // stale: take over with compare-and-set …
}

Hints: the status codes are in lib/errors.ts. SQLite's run() result reports how many rows the statement changed.


Practice: from memory

No scrolling up.

Which of these operations is idempotent?

The phone's checkout request timed out. Which idempotency key does the retry send?

Key k1 arrives again, but with a different amount. What should the server do?

Why claim with INSERT … ON CONFLICT DO NOTHING instead of SELECT, then INSERT?

Worker A's lock went stale and worker B took over. Then A's slow work finishes. What must A's save do?

The shop crashed after PayCo charged, before saving. A retry takes over and re-runs the work. Why is that safe?

Predict the store

🏆 Milestone challenge: Idempotency

cd labs/payflow
npm run test:04            # all 12 green
npm run l4                 # uses YOUR IdempotencyStore
  1. Make it pass. Implement src/idempotency.ts until npm run test:04 is green. Don't open solutions/ until it is.
  2. Explain the numbers. In 2–3 sentences: why does key-per-try produce exactly as many double charges as no-key? And where, in a real mobile app, would you generate and store the key so it survives an app restart?
  3. Write a contract. Pick an endpoint you'd build (or have built) that has a side effect: create an invoice, send a message, start a job. Write its idempotency contract: key scope (per user? per account?), what's fingerprinted, how long keys are kept and why that's longer than your clients retry, what a retry gets while the first is in flight, and what happens when params differ.
  4. Go one hop down. Name one downstream side effect of that endpoint (an email, a payment, a webhook) and say how its repeat is prevented when your handler is re-run after a crash.

Send me back: the npm run test:04 output, results/04-idempotency.json, your answer to 2, and the written contract for 3 and 4.

Pass criteria (check yourself before sending):

When I've reviewed it, I'll write a learning record and unlock Idempotency. Your store is reused as-is in the final challenge.

Read or watch next

Primary source: Making retries safe with idempotent APIs, Malcolm Featonby, Amazon Builders' Library. It's how AWS designs this for EC2: client request tokens, semantically equivalent responses, and parameter mismatches.

💬 Stuck on the take-over case, or unsure what your endpoint's key scope should be? Ask me in the terminal. Paste your failing test output or your draft contract.