A payment request times out. The client retries. Did the first one go through? Without a key nobody knows,
and the safest answer is the one that charges twice.
create unique index on payments (idempotency_key);
insert into payments (idempotency_key, amount) values ($1, $2);
That is the whole spell in two lines. The rest of this scroll is about why those two lines are enough, where
they stop being enough, and how to cast them when the deadline is already behind you.
The problem with "just retry"
Networks fail in three flavors, and only one of them is honest. The request never arrives: retrying is safe.
The request arrives, the work is done, and the response is lost: retrying repeats the work. The request
arrives, the server crashes halfway, and you cannot tell how far it got: retrying might repeat half of it.
From the client's chair these three look identical. You sent bytes, you waited, you got a timeout. Every retry
policy that does not know the difference is a coin toss with someone's money on it.
The usual defenses all fail in a small way. A "check before you insert" query races with itself when two
retries land together. A client-side flag survives only until the app restarts. A long timeout just moves the
ambiguity to a later hour. What you want is a fact that both sides can agree on, written down in the one place
that cannot lose it: the database.
What a key actually is
An idempotency key is a name the client invents for one intention. Not one request: one intention. If the user
presses "pay" once, there is one key, however many times the network makes the client ask.
- Generate it where the intention is born: when the form is opened or the button is pressed, not inside the
retry loop.
- Make it unguessable and unique: a random UUID is fine, a timestamp is not.
- Send it in a header, so it stays out of the payload that gets hashed, logged or validated.
- Store it with the result, never beside it in a second table that can drift.
A key does not make the operation safe. It makes the second attempt recognizable, which is all safety ever
needed.
The unique index does the heavy lifting
The tempting design is a lookup: find a payment with this key, and if none exists, create one. It reads well
and it is wrong, because two retries can both find nothing and both create. The database already has a tool
built for exactly this argument. A unique index lets exactly one writer win and tells every other writer so,
loudly, with an error you can catch.
def charge(key: str, amount: int) -> Payment:
try:
with transaction.atomic():
return Payment.objects.create(idempotency_key=key, amount=amount)
except IntegrityError:
return Payment.objects.get(idempotency_key=key)
Two things matter here. The insert comes first, and the lookup only happens after the index has refused. And
the insert sits inside its own transaction, so a refused insert does not poison whatever transaction the caller
was already in.
Same key, different request
Someone will reuse a key. A bug, a copy and paste, a test fixture that escaped. If the key matches but the
amount does not, returning the old result is silently wrong: the client believes it charged a different sum.
Store a fingerprint of the request next to the key, and compare it on a replay. A match returns the stored
response. A mismatch returns a conflict, with a message that names the problem. It is a rare path and an
extremely useful one, because it turns a bug that would be invisible into one that fails in the first test run.
The half-finished request
The interesting failure is the crash in the middle. The row exists, the work did not finish, and the retry
arrives to find a key that is claimed but has no result. There are two honest answers.
- Wrap the claim and the work in one transaction. Either the key and the result both exist, or neither does,
and the retry simply starts again.
- When the work calls something outside the database, such as a card network, record the key as "in progress"
first, and give the retry a clear answer: wait, or fail with a status that says the first attempt is still
running.
The first is simpler and covers most services. The second is needed the moment a side effect cannot be rolled
back, and it needs a timeout of its own so that an abandoned claim does not block the key forever.
How long to keep keys
Keys are not free. Every one is a row and an index entry, and a table that only grows will one day be the
slowest thing you own. Keep them as long as a retry could plausibly arrive, then let them go.
For most APIs that window is a day or two. Pick it on purpose, write it in the documentation, and delete in
small batches on a schedule so the cleanup does not become the incident. If a client sends a key after its
window, treat it as new, and make sure your documentation says so before someone relies on the opposite.
Checklist for people in a hurry
- One key per intention, created before the first attempt.
- A unique index, and the insert before the lookup.
- A request fingerprint, and a conflict on mismatch.
- The claim and the work in one transaction, or an explicit in-progress state.
- A retention window, written down, with a cleanup job.
- A test that fires the same request twice at once and counts the rows.
That last item is the one people skip, and it is the cheapest. Two threads, one key, one assertion that the
table has a single row. If that test passes, most of the scroll above is already true.
When not to bother
Reads do not need keys. Operations that are naturally idempotent, such as setting a value or deleting by id,
do not need them either. Put the effort where repeating the work costs something: money moved, email sent,
inventory taken, a job queued.
And if you are in a hurry, which is the premise of the title, start with the two lines at the top. A unique
index and an insert will not solve every case, but it will stop the most expensive one, and the rest can be
added the day you meet it.