What this module covers: Why every retry mechanism you have already built obliges you to build this one — the ambiguous failure that makes duplicates structural rather than exceptional. What idempotency actually means (identical effect, not identical response), and the difference between making an operation naturally idempotent and remembering that you already did it. Idempotency keys done properly: client-supplied, scoped, fingerprinted, and inserted as a uniqueness constraint so concurrent duplicates serialise instead of both executing. The atomicity problem when the side effect lives outside your database. Why exactly-once delivery is provably unavailable and exactly-once effect is not. And how wide a dedupe window has to be before it is worth anything at all.
Every Retry You Have Already Chosen Is a Duplicate You Have Already Ordered
You did not decide to have duplicates. You decided to have retries, which is the same decision viewed from the other end.
Count the places in a system built through this course so far that will re-send something. The load balancer retries a request to a second backend when the first connection resets (Module F-11). The HTTP client retries on a 503 with backoff (Module A-6). The queue redelivers after a visibility timeout expires (Module P-14). The Kafka consumer group re-reads from the last committed offset after a rebalance (Module P-15). The webhook sender retries nine times over two days (Module P-17). The background job runner retries a failed job (Module P-19). The user, seeing a spinner, presses the button again — and the user is the retry mechanism with the fewest safeguards and the shortest patience.
Every one of those is correct engineering. Removing them costs you availability. Keeping them means duplicates arrive, and the arithmetic is not favourable: a system with six independent retry mechanisms does not have a small chance of duplicates, it has a routine and continuous supply of them.
The root cause underneath all six is one thing, and it is worth naming precisely because it never goes away:
A caller that does not receive a response has learned nothing about whether the work happened.
text
This is the ambiguous failure, and it is the single most important shape in distributed systems after the partition (Module P-10). A timeout is not a failure. A connection reset is not a failure. A 502 from a proxy is not a failure. Each is an absence of information, and the caller has exactly two options: give up, and risk having lost work that the user believes happened; or retry, and risk doing it twice.
Almost every system chooses retry, because "the payment silently didn't happen" is worse than "the payment happened twice and we can detect it." That choice is at-least-once delivery, and this module is the bill for it.
Analogy: you post a letter containing a cheque and the recipient's confirmation letter is lost in the return post. You cannot tell whether your cheque arrived. If you post a second cheque and the first did arrive, you have paid twice. The fix is not better postage — it is writing an invoice number on the cheque, so the recipient's bookkeeper recognises the second one as a copy of a payment already recorded rather than as a second payment due. The number does the work, not the delivery guarantee.
Why this matters in production: the duplicate does not announce itself. Nothing errors. Your dashboards are green, the queue is drained, latency is normal, and two rows exist where one should. You find out from a customer, from a reconciliation report if you built one, or from a support ticket about being billed twice — typically days later, at which point the remediation is a manual refund and an apology per affected account.
Idempotent Means the Effect Repeats to Nothing, Not That the Response Is Identical
The textbook definition — "an operation that can be applied multiple times without changing the result beyond the initial application" — is correct and slightly misleading, because engineers read "result" as "response body" and it means "state of the system."
Sort the operations you have:
Operation
Idempotent?
Why
GET /orders/42
Yes
reads nothing changed
PUT /orders/42 {status:"shipped"}
Yes
assignment; applying twice sets the same value
DELETE /orders/42
Yes, in effect
second call finds nothing to delete; state is identical
POST /orders
No
creation; twice makes two
UPDATE balance SET amount = amount + 40
No
read-modify-write on a relative value
INCR views
No
same shape, one command
SET status = 'shipped'
Yes
absolute assignment
INSERT ... ON CONFLICT DO NOTHING
Yes
conditional creation
Send email
No, and unfixable
the effect left your system
The pattern separating the two columns is absolute versus relative. An operation that sets a value converges; an operation that adjusts a value accumulates. This is not a small observation — it is the cheapest lever you have, and reaching for it before reaching for keys and dedupe tables is the difference between one line of SQL and a subsystem.
Note also the two entries where idempotency is about effect and not response. A second DELETE /orders/42 may reasonably return 404, and a well-designed create with an idempotency key returns the same 201 with the same body as the original. Both are fine. The contract is about the system's state, and the response is a design choice layered on top — though returning the original response is far kinder to the caller than returning a distinct "duplicate" error they then have to interpret.
The last row is the one that keeps this module honest. Some effects cannot be made idempotent, only deduplicated before they are triggered: an email sent, an SMS delivered, a webhook you already POSTed to a customer, a physical parcel dispatched. For these, there is no absolute form to reach for. The remembering is the whole mechanism, and it must be reliable before the effect, not after.
Idempotency and deduplication are different tools
They get used interchangeably and they are not the same:
Idempotency makes the operation safe to repeat. The duplicate is processed and lands in the same place. No memory is required.
Deduplication makes the system detect that this specific message has been seen before, and skips it. Memory is required — a record of what has been seen, for how long.
Prefer idempotency, because it requires no state, has no window to expire, and cannot be defeated by a cache eviction. Fall back to deduplication when the operation is a creation, a relative adjustment, or an external side effect that has no absolute form.
An Idempotency Key Is the Caller's Promise, Not the Server's Guess
When the operation cannot be made naturally idempotent, the caller has to name the attempt. That name is the idempotency key, and three properties make it work.
It comes from the caller. The server cannot derive it. A hash of the request body is the tempting shortcut and it is wrong in both directions: two legitimately distinct requests can be byte-identical — a customer buying the same £4 coffee twice in a minute is not a duplicate — and one logical retry can differ in bytes if the client regenerates a timestamp or reorders a JSON object. Only the caller knows whether this is the same attempt or another attempt.
It is generated before the first send and reused across every retry of that attempt. A key generated inside the retry loop is a new key per attempt and defeats the entire mechanism. This is the most common client-side bug in idempotency, and it is invisible until the day the retries actually fire.
ts
Where does the key come from in a UI? Mint it when the form is rendered or when the user's intent is first formed — not when the request fires. A key created on button-press gives a new key to a double-click, which is exactly the duplicate you were defending against. A key attached to the form instance makes the second click a duplicate of the first, which is what the user meant.
It is scoped to the caller and the operation. A globally unique keyspace shared across tenants means one customer can guess or collide with another's key and receive their response — a data leak dressed as a cache hit. Store the key as (tenant_id, endpoint, key), and never trust the key alone as a lookup.
The fingerprint: what stops the key from being a lie
A caller can send the same key with a different body — usually by accident (a key stored in a variable that outlived its request) and occasionally on purpose. If you return the first response, you have silently ignored the second request; if you process it, the key meant nothing.
So store a fingerprint of the request alongside the key and compare:
sql
Mismatch is a client bug and deserves a loud, specific error — 422 with a message naming key reuse — rather than either of the silent options. Stripe returns exactly this, and the reason it is worth copying is that the alternative failure is undebuggable from the client's side.
Insert the Key to Claim the Work, Because a Read-Then-Write Loses the Race
Here is the implementation that looks obviously correct and is broken:
ts
Duplicates do not arrive politely spaced out. The ambiguous-failure retry often arrives while the original is still running — that is precisely the case where the response was slow enough to time out. A user's double-click arrives 80 ms apart. A queue redelivery after a worker's visibility timeout arrives while the original worker is still alive and working. So the two requests interleave, both SELECT nothing, and both charge.
The fix is to make the insert itself the claim, and let the database's uniqueness constraint be the arbiter. This is Module F-13's argument about pushing concurrency control into the constraint rather than into application logic, and it is the load-bearing idea of the whole module:
ts
Read the three branches, because each is a real production case with a different correct answer:
Situation
State on the existing row
Response
Why
Same key, different body
any
422 key_reused
client bug; silence would hide it
Same key, work still running
in_flight, lock live
409 + Retry-After
telling the caller to wait is honest; blocking is not
Same key, work finished
succeeded
replay stored response
the caller gets what they would have got
Same key, previous attempt errored
failed
depends — see below
the interesting one
Same key, worker died mid-flight
in_flight, lock expired
recover and re-run
otherwise the key is poisoned forever
The 409 deserves a word, because "just wait for the first request and return its answer" is tempting and expensive. Holding request two open while request one finishes means two connections and two request slots consumed for one unit of work, and under a duplicate storm that is how you exhaust the connection pool (Module P-4) and then the web tier. A short, explicit 409 with Retry-After: 1 costs the caller a round trip and costs you almost nothing.
The expired lock is the failure mode people forget
A worker claims a key, starts the work, and its process is killed — an OOM kill, a deploy, a spot instance reclaimed (Module O-8). The row sits at in_flight forever. Without a lease, every future retry of that key gets 409 in perpetuity: the key is poisoned, the work never completes, and the customer's order is stuck in a state no code can leave.
Hence locked_until. When a retry finds an in_flight row whose lease has lapsed, it may steal the claim — take the row, extend the lease, and re-run. That is safe only if the underlying work is itself re-drivable, which is exactly what the next section is about. Set the lease longer than the operation's p99 and shorter than a human's patience; if the operation can legitimately run for ten minutes, it belongs in a background job (Module P-19) with the key recording the job, not the work.
Should a failed attempt be retryable under the same key?
Split by cause, because conflating them causes real incidents:
Transient failure (timeout to a downstream, deadlock, 503): the key should be released — delete the row or mark it retryable — so the caller's retry can genuinely try again. Storing a permanent failure here means the caller's legitimate retry gets a replayed error forever, and the payment that would have succeeded on attempt two never does.
Deterministic rejection (validation error, card declined, insufficient funds): store it and replay it. Repeating it wastes a call to the payment network, and each declined authorisation attempt is a signal that fraud systems count against the cardholder.
The distinction is the same one Module A-6 draws about what is worth retrying, applied to the record rather than the request.
The Hard Part Is Atomicity: the Key and the Effect Must Land Together
Everything above assumes the key record and the work commit as one unit. When both live in your database, they do — put them in one transaction and the problem disappears:
sql
Either both exist or neither does. This is the easy case, and if your operation is entirely local, stop here.
The hard case is the one that matters: the effect is outside your database. You charge a card, you call a shipping API, you publish to Kafka, you send an email. There is now a gap you cannot close, in either order:
text
There is no ordering of two non-transactional writes that survives a crash between them. Anyone who tells you otherwise has moved the gap, not removed it. What you can do is make one of the two sides recoverable, and there are exactly three honest strategies.
1. Propagate your key to the downstream, so their idempotency covers your gap. This is the best answer whenever it is available, and it is available more often than teams check. Stripe, Adyen, most payment processors, and many modern APIs accept an idempotency key. Derive it deterministically from yours and pass it along:
ts
Now Order A above is safe: the retry re-issues the same charge with the same downstream key, the processor recognises it, no second capture occurs, and it returns the original result — which you then record. Exactly-once effect achieved across a boundary you do not control, using two at-least-once systems and one shared name. The cost is that the downstream's dedupe window now bounds your correctness, so read their documentation for the number and check it exceeds your retry window.
2. Record intent first, then drive the effect from a durable record. This is the transactional outbox (Module A-3) and the shape is worth internalising: in one local transaction write the business row and a pending_effect row. A separate worker reads pending effects and performs them, marking them done. A crash anywhere leaves a pending row that gets retried — so the effect is at-least-once, which is why it must still be idempotent by strategy 1 or 4. What this buys is that nothing is ever lost, and the ambiguity is confined to one place with one retry policy instead of scattered across request handlers.
3. Make it detectable and reconcile. For effects where neither of the above is possible, accept the gap and build the sweeper: a periodic job that lists the downstream's recent operations, compares against yours, and repairs the difference. Payment reconciliation exists for exactly this reason and is not optional in a payments system (Module P-24). The cost is a scheduled job, a watermark, and an alert on non-zero unreconciled counts — which is far cheaper than the alternative, since the alternative is finding out from customers.
4. Choose an operation with a natural unique key downstream. Sometimes you can convert the problem away: instead of "increment the ledger balance," insert a ledger entry with UNIQUE (source_event_id). The duplicate insert fails on the constraint and the balance, computed as a sum, is correct. This is the absolute-versus-relative lever from earlier, and in ledger design it is the whole game — which is why double-entry ledgers are append-only tables with uniqueness constraints rather than mutable balance columns.
Why this matters in production: the incident is not "we sent a duplicate charge." It is that duplicates concentrate during incidents. Latency rises, timeouts fire everywhere at once, every retry mechanism in the system engages simultaneously, and the duplicate rate goes from negligible to a large fraction of traffic in the same ten minutes your team is busy with the original outage. Idempotency is not a refinement you add when you have time; it is the thing that decides whether an availability incident also becomes a financial and data-integrity incident.
Exactly-Once Delivery Does Not Exist; Exactly-Once Effect Does
This distinction is worth being pedantic about, because vendor marketing is not.
Exactly-once delivery is impossible over an unreliable network. The two-generals problem is the short proof: any protocol that requires both parties to know the message arrived needs an acknowledgement, and the acknowledgement is itself a message that can be lost, so it needs acknowledging. There is no finite bottom. Formally, atomic delivery between two parties over a lossy channel cannot be guaranteed, and no amount of protocol design changes it — this is a result about the world, not a limitation of current libraries.
So every real system picks one:
Semantics
Mechanism
Failure mode
When acceptable
At-most-once
send, don't retry (or commit offset before processing)
silent loss
metrics, best-effort telemetry, logs
At-least-once
retry until acknowledged
duplicates
almost everything
"Exactly-once"
at-least-once + idempotent consumer
none, if the dedupe is sound
what you actually want
The third row is the honest formulation: exactly-once effect built from at-least-once delivery plus an idempotent receiver. The delivery layer keeps duplicating; the receiver keeps collapsing them. From the outside the composition behaves as if each message took effect once, which is what anyone asking for exactly-once actually wants.
Read vendor claims through that lens. Kafka's exactly-once semantics (Module P-15) are genuine and narrow: transactions atomically tie consumed offsets to records produced back into Kafka, so a read-process-write pipeline that stays inside Kafka can be exactly-once. The moment your processing charges a card or writes to Postgres outside that transaction, the guarantee stops at the boundary — the external effect is not in the transaction, so it can repeat. Kafka's own broker-side idempotent producer is likewise real and narrow: it deduplicates retries of the same producer's sequence numbers, which prevents duplicates from producer-side retry, not duplicates from your application publishing the same logical event twice.
SQS FIFO deduplicates on a MessageDeduplicationId — within a five-minute window. That number is the entire caveat, and it is smaller than most retry schedules, which brings us to the last section.
A Dedupe Window Shorter Than Your Retry Schedule Is Decoration
Deduplication needs memory, memory costs money, so the window is finite. Choosing its width is the part that gets done by intuition and should be done by arithmetic.
The floor is the maximum age of a duplicate you could receive. Walk your own retry paths and take the longest:
Source of the duplicate
Maximum age
HTTP client retry with backoff
seconds to a minute
SQS redelivery cycles until DLQ
minutes to hours
Kafka consumer replay after a rebalance or restart
as far back as the last committed offset — potentially hours
Webhook sender's schedule (Module P-17)
~2 days
Operator manually replaying a topic or dead-letter batch after an incident
days, and it is a human decision
User retrying from a stale browser tab
unbounded in practice
If you accept webhooks with a nine-attempt, two-day schedule and keep a one-hour dedupe window, you have built something that works in testing and fails in exactly the case it exists for: the receiver was down for three hours, the sender's later attempts land outside your window, and you process the same event twice. The window must exceed the longest retry schedule that can reach it, with margin for the operator replay that nobody put in a design document.
The practical answer for most systems is 24 hours to 7 days, with two refinements:
Compute the storage cost rather than fearing it. A dedupe row of a key, a hash, a small response and timestamps is on the order of 300–500 bytes with index overhead. At 1,000 writes per second for seven days that is roughly 600 million rows and a few hundred gigabytes — genuinely expensive. At 50 writes per second, the same seven days is around 30 million rows and 15 GB, which is nothing. Do this multiplication before choosing; the answer differs by two orders of magnitude between services and intuition tracks neither.
Expire by dropping a partition, not by deleting rows. A DELETE FROM idempotency_keys WHERE created_at < now() - interval '7 days' on a high-write table generates dead tuples faster than autovacuum reclaims them, and Module P-9 walked that exact bloat cascade. Range-partition by day and DROP yesterday's partition — a metadata operation that unlinks files and costs nothing.
Never let Redis be the only dedupe layer for a financially significant effect.SET key 1 NX EX 86400 is a beautiful two-line deduplicator and it is a cache, which means an eviction under memory pressure, a failover to a replica missing recent writes, or a restart without persistence all silently reopen the window — and Module P-12 showed exactly how quietly a Redis instance reaches its memory ceiling. Redis in front of a database check is an excellent optimisation: it absorbs the duplicate storm cheaply and the database constraint remains the authority. Redis instead of the database check trades a duplicate charge against a memory graph you were not watching.
Finally, decide what happens after the window. An event arriving 8 days late with a key you have forgotten will be processed as new. Sometimes that is fine. Sometimes the correct behaviour is to reject anything older than the window outright — the timestamp check in Module P-17's signature verification is this rule applied at the edge, and it converts an unbounded correctness problem into a bounded one you can reason about.
Where This Shows Up in Your Stack
PostgreSQL: the whole mechanism is PRIMARY KEY (tenant_id, endpoint, key) plus ON CONFLICT DO NOTHING, and the insert-as-claim pattern is why. INSERT ... ON CONFLICT takes the row lock the loser waits on, so two concurrent duplicates serialise without you writing a lock. Range-partition the table by day for cheap expiry (Module P-9). For a non-row claim — "only one import job per tenant may run" — pg_advisory_xact_lock(hashtext($1)) gives you a lock keyed on an arbitrary string, released automatically at transaction end. Note the interaction with isolation (Module P-1): under SERIALIZABLE a duplicate can surface as a 40001 serialisation failure rather than a conflict, so the retry loop and the idempotency check are the same code path.
Node.js: generate the key with crypto.randomUUID() at the point of intent, and put it outside the retry loop — the commonest client bug is a key minted per attempt. Beware the in-memory Set or Map used as a deduplicator: it is per-process, so it does nothing once you run two instances, and it is unbounded, so it is a slow memory leak. AbortController timeouts are what create your ambiguous failures, so every place you set one is a place that needs a key.
Redis:SET NX EX is the right first-line dedupe filter for high-volume, low-value events, and INCR is the canonical non-idempotent operation — a redelivered event double-counts, which is acceptable for a view counter and not for a quota that gates billing. Use it as a fast path in front of the database, never as the sole record for money.
Kafka / SQS: Kafka's enable.idempotence prevents duplicates from producer retries only; transactions give exactly-once for Kafka-to-Kafka pipelines only. SQS FIFO's MessageDeduplicationId window is five minutes, which is shorter than most retry schedules — treat it as an optimisation, not a guarantee. In both, the consumer commits after processing (at-least-once) and therefore must be idempotent, which is the contract Module P-15 stated as a fork with only two branches.
Stripe and payment APIs: they accept an Idempotency-Key header precisely because the ambiguous failure is unavoidable, and their window (24 hours for Stripe) is the bound on your correctness when you rely on it. Derive the downstream key deterministically from your own so a retry reproduces it — a randomly generated downstream key inside the retry path is the same bug as the client-side one, one layer down.
Summary
Duplicates are not an anomaly, they are the cost of retries you already chose. The cause is the ambiguous failure: a caller that receives no response has learned nothing about whether the work happened, and no observation from its side distinguishes "never arrived" from "succeeded."
Idempotent means the effect converges, not that the response matches. Absolute operations (SET, PUT, upsert, insert-with-constraint) are naturally idempotent; relative ones (amount + 40, INCR, POST /create) are not. Reaching for the absolute form is one line of SQL instead of a subsystem.
Idempotency and deduplication are different tools. Idempotency makes repetition harmless and needs no memory; deduplication remembers what it has seen and therefore has a window that can expire. Prefer the first, and reserve the second for creations, relative adjustments, and external effects like email that have no absolute form.
The idempotency key must come from the caller, be minted once outside the retry loop, and be scoped per tenant. A server-side body hash cannot distinguish two legitimate identical purchases from one retry. Store a request fingerprint with the key and return 422 on reuse with different content, because both silent options are worse.
Claim the key with the insert, not with a read-then-write.INSERT ... ON CONFLICT DO NOTHING makes the uniqueness constraint the arbiter, so concurrent duplicates — which is how they actually arrive — serialise. Handle four distinct outcomes: fingerprint mismatch (422), in-flight with a live lease (409 + Retry-After), completed (replay the stored response), and in-flight with an expired lease, which without a locked_until poisons the key forever after a worker is killed mid-flight.
Release the key on transient failures and store it on deterministic ones. Persisting a timeout as a permanent failure means the caller's legitimate retry replays an error forever and the charge that would have succeeded never does.
Atomicity across a non-transactional boundary is the real problem, and no ordering of two writes survives a crash between them. The four honest strategies: propagate your key to the downstream so their dedupe covers your gap; record intent locally and drive the effect from a durable record (the outbox, Module A-3); reconcile against the downstream on a schedule; or restructure the effect so it has a natural unique key, which is why ledgers are append-only tables with UNIQUE (source_event_id) rather than mutable balances.
Exactly-once delivery is provably unavailable; exactly-once effect is achievable as at-least-once delivery plus an idempotent receiver. Kafka's exactly-once is real but bounded to Kafka-to-Kafka transactions, and its idempotent producer deduplicates producer retries only — neither covers a card charge inside your consumer.
A dedupe window shorter than the longest retry schedule that can reach it is decoration. Webhook senders retry for two days and operators replay batches by hand, so 24 hours to 7 days is the usual answer — chosen by multiplying row size by write rate, not by intuition, since the storage answer varies a hundredfold between services.
Expire dedupe records by dropping a daily partition, not by DELETE, or the table bloats faster than autovacuum reclaims it. And never make Redis the only dedupe layer for money: an eviction or a failover silently reopens the window. In front of a database constraint it is an excellent optimisation; instead of one, it is a duplicate charge waiting on a memory graph nobody is watching.
Duplicates cluster during incidents, when latency rises and every retry mechanism engages at once. That is why this is foundational rather than a refinement — it decides whether an availability incident stays one, or also becomes a financial one.
The next module, Webhooks and Callback Architectures, is this module applied across a boundary you do not control. As a sender you will retry for two days and therefore create duplicates by design; as a receiver you will get them from a sender whose behaviour you cannot change. Everything here — the stable event ID, the insert-as-claim, the window wide enough to cover the schedule — becomes the contract between two companies rather than two functions.
Knowledge Check
A team implements idempotency keys for their POST /charges endpoint. The handler does SELECT on the key, returns the stored response if a row exists, otherwise charges the card and then inserts the key row. Testing passes, including a test that sends the same key twice in sequence. In production they still see duplicate charges, and the duplicates cluster during periods of elevated latency. What is wrong?
Your service consumes an event stream and, for each event, calls an external payment processor to capture funds. You want each event to result in exactly one capture. The processor accepts an idempotency key. What does the module prescribe, and what remains true afterwards?
You accept webhooks from a partner whose documented retry schedule is nine attempts spread over roughly two days. Your receiver deduplicates on the partner's event ID using SET <id> 1 NX EX 3600 in Redis. A quiet three-hour outage on your side is followed, days later, by a reconciliation report showing a small number of double-processed events. What are the two defects?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
Client Server
|----- POST /charge --------------->|
| | charge succeeds, $40 captured
| X (response lost) | row committed
| |
| timeout fires. Two possibilities: |
| (a) request never arrived |
| (b) it arrived and succeeded |
| and no observation distinguishes |
| them from here. |
// Correct: the key names the user's intent, and survives the whole retry sequence.const idempotencyKey = crypto.randomUUID();// ONCE, outside the loopfor(const delay of[0,200,1_000,5_000]){if(delay)awaitsleep(delay + Math.random()* delay);// jitter, Module A-6const res =awaitfetch("/charges",{ method:"POST", headers:{"idempotency-key": idempotencyKey,"content-type":"application/json"}, body,// same bytes every attempt});if(res.status <500&& res.status !==429)return res;// 4xx won't improve}
CREATETABLE idempotency_keys ( tenant_id bigintNOTNULL, endpoint textNOTNULL,keytextNOTNULL, request_hash bytea NOTNULL,-- sha256 over the canonical request state textNOTNULL,-- 'in_flight' | 'succeeded' | 'failed' response_code int, response_body jsonb, resource_id bigint,-- what we created, for the replay created_at timestamptz NOTNULLDEFAULTnow(), locked_until timestamptz,PRIMARYKEY(tenant_id, endpoint,key));
// BROKEN under concurrency.const existing =await db.oneOrNone("SELECT * FROM idempotency_keys WHERE key = $1",[key]);if(existing)returnreplay(existing);awaitcharge(...);// two requests both got hereawait db.query("INSERT INTO idempotency_keys ...");
const claim =await db.oneOrNone(`INSERT INTO idempotency_keys (tenant_id, endpoint, key, request_hash, state, locked_until)
VALUES ($1, $2, $3, $4, 'in_flight', now() + interval '60 seconds')
ON CONFLICT (tenant_id, endpoint, key) DO NOTHING
RETURNING key`,[tenantId, endpoint, key, hash],);if(!claim){// Somebody else owns this key. Three cases, three different answers.const row =await db.one(`SELECT * FROM idempotency_keys WHERE (tenant_id, endpoint, key) = ($1,$2,$3)`,[tenantId, endpoint, key],);if(!row.request_hash.equals(hash))return res.status(422).json({ error:"key_reused"});if(row.state ==="in_flight"&& row.locked_until >newDate())return res.status(409).json({ error:"request_in_progress"});// retry laterif(row.state ==="in_flight"){/* lock expired — recover, see below */}return res.status(row.response_code).json(row.response_body);// replay}// We own the key. Do the work.
BEGIN;INSERTINTO idempotency_keys (...)VALUES(...)ON CONFLICT DO NOTHING;-- claimINSERTINTO orders (...)VALUES(...)RETURNING id;-- effectUPDATE idempotency_keys SET state='succeeded', response_body=$1, resource_id=$2WHERE(tenant_id,endpoint,key)=(...);COMMIT;
Order A: charge first, then record the key
charge succeeds → process dies → key never recorded → retry charges again.
Order B: record the key first, then charge
key recorded → process dies → charge never happened → retry sees 'succeeded'
and replays a success for money that was never taken.
await stripe.paymentIntents.create({ amount, currency:"gbp", customer },{ idempotencyKey:`${tenantId}:charge:${key}`},// deterministic, so a retry matches);