Guide · 8 min read
Idempotency is easy to describe and easy to get subtly wrong.
first attempt, timed out client side
POST /v1/transactions
Idempotency-Key: payout:8821
201 Created
{ "id": "txn_41", "status": "posted" }the retry
POST /v1/transactions
Idempotency-Key: payout:8821
200 OK
{ "id": "txn_41", "status": "posted" }Same id, no second movement. The caller never learned whether the first one landed, and it did not need to.
Define this once
Five cases, and what each one has to do.
| Case | Correct behaviour |
|---|---|
| Same key, same body, first time | Process it. Store the result against the key. |
| Same key, same body, replay | Return the stored result. Do not process again, and do not report an error. |
| Same key, different body | Refuse loudly. This is a caller bug and swallowing it moves money twice. |
| Same key, still in flight | Refuse with a retryable status. The caller tries again and gets the stored result. |
| New key, identical body | Process it. Two genuinely separate payments can look identical, and it is not the ledger job to guess. |
How it goes wrong
Five failure modes that all look like working code.
- The key that is generated per attempt
- A fresh UUID on every retry is not an idempotency key, it is a request id. The key has to be derived from the thing being paid for, so the second attempt presents the same one.
- The key with no lifetime
- Kept forever, it is a table that grows without bound. Kept for an hour, a delayed retry from a stuck queue creates a second payment. Pick a window longer than your longest retry, and say what it is.
- The same key, a different body
- The dangerous one. A caller reuses a key with a changed amount. Returning the original result silently is wrong, and so is processing the new amount. It has to be a loud error.
- The key scoped too widely
- Keys unique per account, not globally, or two tenants collide and one gets the other result. Scope is part of the key.
- The concurrent duplicate
- Two identical requests in flight at once, before either has committed. A uniqueness check that reads then writes lets both through. It has to be one atomic write that the second one loses.
The key itself
Derive it, do not generate it.
Idempotency is the load-bearing part of moving off a stored balance too, because a backfill has to be safe to re-run: migrating to a double-entry ledger. Kordio takes the key in a header or in the body, so scripting against it is not annoying, and every write inthe ledger requires one.
const key = `payout:${payout.id}:attempt`;
await kordio.transactions.create({
idempotencyKey: key,
postings: [
{ accountId: 'acc_merchant_payable', amount: -amountCents, currency: 'EUR' },
{ accountId: 'acc_bank_clearing', amount: amountCents, currency: 'EUR' },
],
});the same key with a changed amount
409 Conflict
{
"error": {
"code": "idempotency_key_reuse",
"hint": "payout:8821:attempt was used with a different body.
Use a new key, or replay the original request."
}
}Refusing here is the whole value. A system that quietly returns the first result for a changed request is worse than one with no idempotency at all, because it looks correct.
Questions
The ones with non-obvious answers.
Is a unique constraint on the key enough?
It is most of the way there, and it is the right primitive because it is atomic. What it does not give you on its own is the stored response for a replay, or the different-body check. Those are the parts teams skip.
How long should a key live?
Longer than the longest retry any of your callers can perform, including a queue that was paused over a weekend. Twenty-four hours is a common answer and it is too short more often than people expect.
What should a replay return, the original status or a fresh one?
The original result, unchanged, including the original identifiers. A caller replaying a request is trying to find out what happened the first time, not to start something new.
Does this replace a distributed transaction?
No, and it is not trying to. Idempotency makes a retry safe. It does not make two systems agree. If you need both to commit or neither, you still need that design; idempotency is what makes each attempt at it survivable.
Where does this stop mattering?
Reads, and writes you can safely repeat. If replaying the operation cannot cost anyone money or create a duplicate obligation, none of this is worth the table.