PATTERNARCHITECTURE

A pattern for idempotency as a contract

A structure for idempotency as a contract that has survived credit operations and four sectors that share none of its vocabulary.

FILED
READ
AUTHOR
REF

A pattern earns its keep by being cheaper to apply than to argue about. This one has survived credit operations and four other sectors that share none of the same vocabulary, which usually means the shape is real and not a coincidence.

Anything reachable over a network will be called twice. Not because of a bug, but because a timeout gives the caller no way to know whether the first attempt landed. Make the second call harmless and a whole category of incident disappears.

THE TIMEOUT PROBLEM

When a request times out, the caller knows exactly one thing: no response arrived. It doesn't know if the work happened. Its two options are to retry, and risk doing the thing twice, or not retry, and risk not doing it at all. Neither is safe unless you've made a promise about repeats.

That promise is the contract. The caller sends a key it chose; you guarantee that the same key means the same operation, no matter how many times it arrives. Now retrying is boring, which is what you want at 3am.

THE KEY HAS TO COME FROM THE CALLER

Generating the key server-side defeats the point. The caller can't tell you "this is the same request as before" if it doesn't own the identifier. The key belongs to the intent, so it's created once, when the decision is made, and reused on every attempt.

It also needs a lifetime and a stored result. Recording that a key was used isn't enough; you have to be able to return what happened last time, or the caller still can't tell success from a duplicate. That's the part most implementations skip.

It paid for itself the first time an approval was issued against a stale valuation. Because the structure was already there, recovery was a query instead of an investigation, and underwriters heard it from the system rather than from a customer.

export async function reserve(key: IdempotencyKey, cmd: Reserve) {
  const seen = await store.get(key)
  if (seen) {
    // Not an error. The caller timed out and is asking again.
    if (seen.fingerprint !== fingerprint(cmd)) throw new KeyReuseConflict(key)
    return seen.result
  }

  const result = await db.tx(async (t) => {
    const r = await allocate(t, cmd)
    // Result and key committed together, or neither.
    await store.put(t, key, { fingerprint: fingerprint(cmd), result: r })
    return r
  })

  return result
}

WHERE IT MATTERS MOST

Money and side effects. Anything that moves a balance, sends a message, allocates a resource or notifies a human deserves this treatment. Read paths generally don't need it, which is a useful way to keep the machinery contained.

The awkward cases are the ones with an external side effect you don't control. There the honest answer is to record your intent before you act, so a replay can at least tell you what you were trying to do.

At a specialist asset lender it turned a recurring escalation into an ordinary state the system could describe. Around 2,500 applications a month went through without a single manual reconciliation, which hadn't been true of any quarter before it.

WHERE IT GOES WRONG

  • Keys with no expiry, so the table grows forever and nobody dares add an index.
  • Recording that a key was used but not what it returned, so retries still can't tell success from duplicate.
  • Generating the key server-side, which makes it impossible for a caller to identify a repeat.
  • Writing the key outside the transaction that does the work, leaving a window where one exists without the other.

The caller owns the key. You owe them the same answer every time.

WHEN TO REACH FOR IT

It's cheap on day one and expensive to retrofit, which makes it a default rather than a decision. We put it in the first commit and haven't regretted it yet.

RELATED
SAME GROUND, DIFFERENT ANGLE
ALL TRANSMISSIONS