Idempotency keys: ask twice, execute once

Lesson 3 of 5 in Reliability Plumbing: Timeouts, Retries, Idempotency, Breakers.

Here is the shape of the problem. An agent is a non-deterministic process that emits requests, running on infrastructure that loses responses. Both halves independently produce duplicates: the model may re-request an action it is unsure about, and the network may deliver an action whose acknowledgement never came back.

You cannot fix either half. You can make duplicates harmless.

An idempotency key is a caller-supplied identifier for an intended action, not for an attempt. Present the same key twice and the system executes once and returns the same answer both times. The key converts at-least-once delivery — which is all your transport can promise — into effectively-once execution, which is what a refund needs.

A retry meets the idempotency check

  1. Model emits refund(order=8842, amount=42.00)

    This may be the first request, a runtime retry after an ambiguous timeout, or the model asking again because it never saw a result. The runtime cannot tell them apart — and does not need to.

  2. Runtime derives the key

    Deterministic function of run id + logical step + tool name + canonical arguments. The same intended action derives the same key on every attempt, including after a process restart.

  3. Key already in the idempotency store?

    The store — not the tool, not the model — is the arbiter. This lookup is the entire mechanism.

  4. Atomically insert key with status = in-flight

    Insert-if-absent, in one atomic operation. Two concurrent attempts race here; exactly one wins the insert and the loser follows the "already exists" path.

  5. Call the payment provider, passing the key downstream

    End-to-end or nothing: if the provider also honours the key, a duplicate that slips past your store still collapses at theirs.

  6. Persist the response against the key

    Store the result, not just the fact of completion — that is what makes the replay path return the same answer instead of a vague "already done".

  7. Is the prior attempt still in flight?

    Distinguish "someone is doing it right now" from "it is finished". Conflating them produces either duplicate work or a lie about completion.

  8. Return in-progress. Execute nothing.

    Back off and re-check, or return a typed retry-later observation. Never execute alongside an in-flight attempt — that is the duplicate you were preventing.

  9. Replay the stored response verbatim

    Cheap, side-effect free, and consistent. The model sees the result it would have seen the first time.

  10. Exactly one refund; one observation in context

Read the diagram again and notice where the intelligence is not. The model has no idea any of this happened. It asked for a refund and got a refund result — once. Every duplicate was absorbed two layers below the reasoning.

That is the design goal for all reliability plumbing: the model should experience a simpler, more reliable world than the one your runtime actually lives in. Every ambiguity you resolve in the plumbing is an ambiguity the model does not get to improvise about.

1 · Derivation — what actually goes into the key

A key must be deterministic (the same intended action derives the same key after a crash, a resume, or a retry) and distinguishing (two genuinely different refunds derive different keys).

A workable recipe: hash of run_id + logical step index + tool name + canonicalised arguments. Canonicalising matters — key order and float formatting must not change the hash, or your key stops matching itself.

What must not go in: a random UUID generated per attempt (defeats the purpose), a timestamp (same), or a string the model wrote (see the warning below).

2 · Scope and lifetime — how long a key must be remembered

The key must outlive the longest window in which a duplicate can arrive. That is not "the retry window" — it includes worker restarts, queue redelivery, and a human clicking retry tomorrow morning.

Set the retention longer than the maximum time a duplicate could plausibly appear, and longer still if a human can re-trigger the run. A key that expires before the duplicate arrives is the same as no key at all, and the failure looks exactly like the bug you thought you fixed.

Scope by the business action, not the network call: one refund of one order is one key, even if three internal services participate.

3 · Atomic claim — the store is the arbiter

The check-then-execute sequence must not be two independent steps, or two concurrent attempts both read "not present" and both execute. Use a single atomic insert-if-absent (a unique constraint, a conditional write) and let the loser take the "already exists" branch.

This is why the idempotency store lives in your runtime’s durable state, not in memory. In-process maps are wiped by the deploy that caused the duplicate.

4 · Store the response, not just the fact

Recording only "done" forces the replay path to return something vague, and a vague answer to a model is an invitation to try a different tool. Persist the actual response body so a replay is indistinguishable from the original success.

It also gives you the reconcile answer from lesson one: after an ambiguous timeout, re-presenting the key tells you both whether it landed and what the result was.

5 · End-to-end — pass the key downstream

Your store stops duplicates that reach your runtime. It cannot stop a duplicate created between your runtime and the provider — a retry inside an HTTP client, a gateway replay.

So propagate the key to any downstream API that accepts one (many payment, messaging, and provisioning APIs do). Where the downstream offers no key, the fallback is a deterministic natural id you can query before writing — invoice_8842_refund — plus a read-before-write check. Uglier, and still far better than hoping.

Interactive checkpoint quiz (2 questions) — open this page in a browser to take it.