R3 — Idempotency: making a retried operation take effect exactly once

Prerequisite: R0. This section assumes the three guarantees, the difference between exactly-once delivery, processing and effect, and the duplicates-versus-loss asymmetry.

Request lifecycle with an idempotency key
flowchart TD
  R["Request arrives with key"] --> I["INSERT key ON CONFLICT DO NOTHING"]
  I -->|"row returned: we own it"| P1["Phase 1: local txn + recovery_point"]
  I -->|"conflict: key exists"| S{"Stored state?"}
  S -->|finished| RET["Return stored response verbatim"]
  S -->|"started, lease valid"| C409["409 Conflict"]
  S -->|"different request hash"| C422["422 — client bug"]
  P1 --> EXT["Call rail / PSP (cannot roll back)"]
  EXT --> P2["Phase 2: record outcome"]
  P2 --> FIN["Mark finished, store response"]
  EXT -->|"crash or timeout"| REC["Lease expires, recovery worker<br/>resumes from recovery_point"]
  REC --> Q["Query rail by end-to-end reference"]
  Q --> FIN

Exactly-once effect is what payments require, and you get it by making the operation idempotent at its boundary and deduplicating retries against a durable record. This is how that is actually built.

Interactive — pick a scenario

waiting processing complete failed duplicate, dropped compensated being checked never arrived

Scenario 4 is the argument for this section in one picture: 2 cannot replay to 3.1 alone, so 3.2 and 3.3 receive the message a second time. Whether that is harmless or catastrophic is decided entirely by what follows.

The mechanism

The client generates a key (a UUID) and sends it with the request. The server stores that key immediately, in the same database it will write the business record to, and uses a unique constraint on it. Concurrent duplicates then collide at the database rather than racing through your code.

CREATE TABLE idempotency_keys (
  client_id      bigint NOT NULL,      -- keys are unique per client, not globally
  key            text   NOT NULL,      -- client-supplied, untrusted
  request_hash   bytea  NOT NULL,      -- reject same key, different body
  endpoint       text   NOT NULL,
  state          text   NOT NULL,      -- started | finished
  recovery_point text,                 -- which phase completed
  response_code  int,
  response_body  jsonb,
  locked_at      timestamptz,          -- lease, not a lock
  created_at     timestamptz NOT NULL,
  PRIMARY KEY (client_id, key)
) PARTITION BY RANGE (created_at);

The lifecycle. On arrival: insert the key, or find it. If found and finished, return the stored response verbatim — not a freshly computed one, because the world may have changed since. If found and still started and locked recently, return 409. If the request body hash differs from the stored one, reject: the client reused a key for a different payment.

Atomic phases. Split the handler into phases, each one a local transaction that also advances recovery_point. Between phases you may call a rail or a PSP, which cannot be rolled back. If the process dies mid-flight, a recovery worker picks the key up and resumes from the last recorded phase. This is the part most candidates miss: idempotency is not one wrapper, it is a small state machine with a resume path.

Transient failures are not results. A 429 or a 503 should not be stored as the final response, or the client can never retry successfully.

The timeout-after-debit case. You debited, then the rail timed out. Never blind-retry. Query the rail by your own reference, or mark the payment indeterminate and let reconciliation settle it. Every payment request carries an end-to-end reference precisely so this lookup is possible.

Scope the key, or it is a security hole. The primary key is effectively (client_id, key). If keys are global, one client can present another's key and receive their stored response — a data leak dressed as a cache hit. Reject a key whose stored client_id does not match the caller, with 404 rather than 403, so keys cannot be enumerated.

Winning the race without a distributed lock. Let the database arbitrate:

INSERT INTO idempotency_keys
       (client_id, key, request_hash, endpoint, state, locked_at, created_at)
VALUES ($1, $2, $3, $4, 'started', now(), now())
ON CONFLICT (client_id, key) DO NOTHING
RETURNING client_id, key;

Rows returned means you own the request — proceed. Nothing returned means it exists: load it and branch on state. No Redis, no lock service, no clock skew.

Leases, not locks. locked_at is a lease with a short expiry. A process that dies mid-request leaves the lease to expire, after which a recovery worker (or the next retry) may take over from recovery_point. Without the lease you get either permanently stuck keys or two workers in the same phase.

Response matrix worth memorising.

Situation Return
New key Process normally, store the response
Key finished The stored response, byte-for-byte, with a replay marker header
Key in progress, lease valid 409 Conflict — the client should retry later
Same key, different request body 422 — client bug, never process
Stored response was 429 or 5xx Do not store it as final; allow a genuine retry

Synthetic versus natural keys. Client-facing APIs take a client-generated UUID. Internal service-to-service calls should derive the key deterministically — hash(payment_id + step_name) — so no coordination or storage is needed on the caller's side and a replayed command is inherently safe.

The consumer side is the same problem. An event consumer needs an inbox: insert the message ID into a processed-messages table in the same transaction as the effect. If the insert conflicts, you have seen it; drop it.

Commit offsets after, never before.

Never put a non-transactional side effect in the middle of a phase.

Good practices

The phase state machine

The handler in this example has five steps: Handler1 → External system 1 → Handler2 → External system 2 → Handler3. The request is fulfilled only when Handler3 commits. Three columns on the key row carry all the state.

Column Meaning Who changes it
state started while any phase is outstanding, finished once Handler3 has committed. Only finished is a result The owner of the request, in the same transaction as the work
locked_at A lease, not a lock. Set on acquisition, refreshed at each phase boundary, cleared on completion. A stale value means the owner died Whoever currently owns the request
recovery_point The last phase that definitely committed. The resume instruction The owner, in the same transaction as the work of that phase

The rule that makes external calls safe: record intent before the call, record outcome after it. Two commits bracket every external call, so a crash is always interpretable.

recovery_point What has definitely happened What a resumer must do
null Key row exists, no work done Run Handler1
H1_DONE Handler1 committed. E1 was never called — the intent commit had not happened Call E1 with derived key K1
EXT1_SENT Intent recorded. E1 may or may not have been called and may or may not have applied it Probe E1 by K1; if unknown, call again with the same K1
EXT1_DONE E1 applied it and its result is stored Run Handler2
H2_DONE Handler2 committed. E2 never called Call E2 with derived key K2
EXT2_SENT Intent recorded. E2 outcome unknown Probe E2 by K2; if unknown, call again with the same K2
EXT2_DONE E2 applied it and its result is stored Run Handler3
— (state=finished) Everything committed, response stored Return the stored response

Derived keys matter here: K1 = hash(request_key + "ext1") and K2 = hash(request_key + "ext2") are deterministic, so a resumed attempt presents the same key the dead attempt used, and the external system treats it as a replay rather than a second instruction.

Diagram 1 — happy path

sequenceDiagram
  autonumber
  participant C as Client
  participant S as Payment service
  participant DB as Database
  participant E1 as External system 1
  participant E2 as External system 2

  C->>S: POST /payments with Idempotency-Key K
  S->>DB: INSERT key K, state=started, locked_at=now, recovery_point=null
  DB-->>S: row inserted, this attempt owns the request
  S->>DB: TXN Handler1 work + recovery_point=H1_DONE + locked_at=now
  S->>DB: TXN recovery_point=EXT1_SENT, derived key K1, attempt=1
  S->>E1: call, Idempotency-Key K1
  E1-->>S: 200, result R2
  S->>DB: TXN store R2 + recovery_point=EXT1_DONE + locked_at=now
  S->>DB: TXN Handler2 work using R2 + recovery_point=H2_DONE + locked_at=now
  S->>DB: TXN recovery_point=EXT2_SENT, derived key K2, attempt=1
  S->>E2: call, Idempotency-Key K2
  E2-->>S: 200, result R3
  S->>DB: TXN store R3 + recovery_point=EXT2_DONE + locked_at=now
  S->>DB: TXN Handler3 work + state=finished + response stored + locked_at=NULL
  S-->>C: 201 Created, response body

In words. The insert is the admission control: because (client_id, key) is the primary key, exactly one concurrent attempt gets a row back and owns the request. Every phase is its own transaction that commits the business work and the new recovery_point together — so there is no window where work exists without a marker, or a marker without work. locked_at is refreshed at each boundary, which is what tells a watching recovery worker that the owner is alive rather than wedged. Before each external call the server commits EXT_SENT with the derived key; after the call it commits the outcome. The response body is written only in the final transaction, alongside state=finished, so nothing can return a result for a request that is not actually complete. Clearing locked_at at the end is hygiene: a finished row needs no lease.

Diagram 2 — client retries before the request is fulfilled

sequenceDiagram
  autonumber
  participant C as Client
  participant A as Server instance A
  participant DB as Database
  participant B as Server instance B
  participant E1 as External system 1

  C->>A: POST with key K
  A->>DB: INSERT K, state=started, locked_at=t0
  DB-->>A: inserted, A owns it
  A->>DB: TXN Handler1 + recovery_point=H1_DONE + locked_at=t1
  A->>E1: call with K1, slow response
  Note over C: client's HTTP timeout fires, it retries
  C->>B: POST with the same key K
  B->>DB: INSERT K ON CONFLICT DO NOTHING
  DB-->>B: no row returned, the key already exists
  B->>DB: SELECT state, request_hash, locked_at, response
  DB-->>B: started, hash matches, locked_at=t1 is fresh
  B-->>C: 409 Conflict + Retry-After, no work done
  E1-->>A: 200, result R2
  A->>DB: EXT1_DONE, then Handler2, EXT2, Handler3
  A->>DB: TXN state=finished + response stored + locked_at=NULL
  A-->>C: 201 Created
  C->>B: a later retry with key K
  B->>DB: SELECT
  DB-->>B: finished + stored response
  B-->>C: 201 with Idempotency-Replay true, byte-identical body

In words. The retry never reaches any handler. ON CONFLICT DO NOTHING returns no row, which is the signal that someone else owns this request, and the branch is decided entirely by what is on the row. state=started with a fresh locked_at means an attempt is genuinely in flight, so the correct answer is 409 with a Retry-After — not 200, because there is no result yet, and not 500, because nothing has failed. Two variants matter: if request_hash differs from the stored one the answer is 422, since the client has reused a key for a different payment and processing it would be the worst possible outcome. And once A finishes, the same retry path finds state=finished and returns the stored response verbatim with a replay marker — the client cannot tell which attempt did the work, which is the entire point.

Diagram 3 — timeout while talking to External system 2

sequenceDiagram
  autonumber
  participant C as Client
  participant S as Payment service
  participant DB as Database
  participant E2 as External system 2

  Note over S,DB: state=started, recovery_point=H2_DONE, lease held
  S->>DB: TXN recovery_point=EXT2_SENT, derived key K2, attempt=1
  S->>E2: call with Idempotency-Key K2
  Note over S,E2: no response inside the timeout, outcome genuinely unknown
  S->>E2: probe, GET status by K2 or by our end-to-end reference
  E2-->>S: no answer, or "unknown"
  S->>DB: TXN leave recovery_point=EXT2_SENT, record attempt + last_attempt_at, locked_at=NULL
  S-->>C: 202 Accepted, or 409 with the payment id
  Note over DB: row survives as the instruction to resume

In words. A timeout is not a failure — it is an absence of information, and the difference is money. The server must not tell the client the payment failed, because E2 may have applied it, and it must not tell the client it succeeded, because E2 may not have. recovery_point stays at EXT2_SENT, which is exactly the marker that says "an instruction may be out there with key K2". One cheap probe is worth trying inline; if it also comes back unknown, the attempt stops. Releasing the lease (locked_at=NULL) is deliberate — it invites the next actor, a retry or the recovery worker, to take over immediately instead of waiting out the lease. Handler3 must not run: the request is not fulfilled, and marking it finished would leave the books claiming an outcome nobody has confirmed. If E2 honours idempotency keys, a bounded inline retry with the same K2 is safe and often resolves it; if E2 offers neither keys nor a status query, the request is parked as indeterminate and reconciliation settles it against E2's statement.

Diagram 4 — an incomplete request resumed on a client retry

sequenceDiagram
  autonumber
  participant C as Client
  participant S as Server instance C
  participant DB as Database
  participant E2 as External system 2

  C->>S: retry POST with key K
  S->>DB: INSERT K ON CONFLICT DO NOTHING
  DB-->>S: no row, key exists
  S->>DB: SELECT state, request_hash, recovery_point, locked_at
  DB-->>S: started, hash matches, recovery_point=EXT2_SENT, locked_at stale
  S->>DB: UPDATE SET locked_at=now WHERE state='started' AND locked_at < now - lease RETURNING recovery_point
  DB-->>S: 1 row, takeover won
  Note over S: resume at EXT2_SENT, never re-run Handler1 or Handler2
  S->>E2: probe by K2
  alt E2 already applied it
    E2-->>S: found, result R3
    S->>DB: TXN store R3 + recovery_point=EXT2_DONE
  else E2 never received it
    E2-->>S: not found
    S->>E2: call again with the same key K2
    E2-->>S: 200, result R3
    S->>DB: TXN store R3 + recovery_point=EXT2_DONE
  end
  S->>DB: TXN Handler3 + state=finished + response stored + locked_at=NULL
  S-->>C: 201 Created, the final response

In words. This is the payoff for everything above. The retry finds a started row whose lease has expired, which means the previous owner is dead. Takeover is a single conditional UPDATE that stamps a new locked_at only if the row is still started and still stale, and returns the row — if two retries arrive together, exactly one gets a row and the other gets zero and answers 409. The new owner reads recovery_point and resumes from there: Handler1 and Handler2 are not re-run, because their markers say they committed, and re-running them would double their effects. EXT2_SENT is ambiguous by construction, so the resumer probes first and only calls again with the identical derived key K2, which makes a second delivery a no-op on E2's side. Handler3 then runs, the response is stored, state becomes finished and the lease is cleared, and this retry — not the original request — returns the successful response to the client.

A background recovery worker does exactly the same thing on a schedule, sweeping rows that are started, stale and older than a threshold. Whichever gets there first, the outcome is identical; a client retry that arrives after the worker has finished simply sees finished and receives the stored response.

Correctness checks worth stating aloud

Generating and storing keys

The client owns key generation, and that is the whole problem. The server cannot generate the key, because the client needs to present the same value on a retry — if the server minted it, a retry after a lost response would arrive with no key at all. So the key is client-supplied, and you must assume clients will supply anything: 1, order-123, a reused batch number, an empty string.

The single most common client bug is generating the key inside the retry loop, which produces a fresh key per attempt and defeats the entire mechanism.

How a client should generate one

Uniqueness is per client, not global. This is the design point people get wrong. Keys come from mutually unaware clients, so two tenants will eventually send req-001 on the same day. A global unique constraint would make the second one collide, and — far worse — a collision means returning tenant A's stored response to tenant B. Treat the key as data inside a tenant's namespace. The schema at the top of this section already carries that decision: PRIMARY KEY (client_id, key), with the key stored as text because clients supply it.

The composite key gives you three things at once: no cross-tenant collisions, no cross-tenant information leakage, and a natural alignment if you later shard by client, since a tenant's keys live with that tenant's data.

Whether to include the endpoint in the scope.

Scope Behaviour when a client reuses a key on a different endpoint When to choose it
(client_id, key) Rejected as a mismatched request — 422 Default; matches the Idempotency-Key header convention and catches client bugs loudly
(client_id, endpoint, key) Both succeed independently Multi-tenant platforms where teams share a client ID and collide accidentally

Prefer the first. A client reusing one key for two different operations has a bug, and surfacing it beats silently accommodating it.

Store it where the effect is. The key record must commit in the same transaction as the business write. Putting keys in Redis or DynamoDB while the payment lands in Postgres recreates the dual-write problem you were trying to solve: the key says done, the payment never happened. If a separate store is unavoidable — a distributed front door, multi-region — use a conditional write with a fencing token and accept that reconciliation is now part of the design rather than a nicety.

Operating the key table — it is one of the highest-write tables you own

What to say in the interview

"The boundary is at-least-once, so I make the effect idempotent rather than trying to make delivery unique. The client supplies a key, it's unique per client rather than globally, and the key record commits in the same transaction as the work. Multi-step operations get phases with a recovery point, so a crash resumes instead of restarting — and where the outcome is genuinely unknown, I record it as indeterminate and let reconciliation settle it rather than retrying blindly."