notes~/ notes/retry-idempotency
~/ cat notes/retry-idempotency.md

A timeout is not a failure. It is an absence of information.

Between “I sent the request” and “I recorded success” there is always a window. If the process dies inside it, local state says IN_PROGRESS while the remote side may be not started, running, or already done. Every pattern here exists to resolve that ambiguity safely.

UNKNOWN ≠ failedIN_PROGRESS is not a final stateatomic claim, never SELECT-then-actlease makes ownership provable

00The core idea

local state   = IN_PROGRESS
remote truth  = { NOT_STARTED | RUNNING | COMPLETED }

IN_PROGRESS is not a final truth.
It is an ownership and recovery state.

Retry and recovery are different jobs. Retry is trying again when the failure is transient or safely repeatable. Recovery is reconstructing what actually happened after a crash, timeout or lost acknowledgement, and then continuing without corrupting anything.

five questions a production design must answer

What identifies one logical operation? Can duplicates arrive concurrently (assume yes)? Can the operation be safely replayed? Which side effects fit in one transaction and which cross a boundary? How do you tell a live owner from a crashed one?

01Timeouts are ambiguous

local stateIN_PROGRESStimeoutremote reality — all three are possibleNOT STARTEDRUNNINGALREADY COMPLETEDretrying blindly is only safe in the first two1. is there a status API?read the truth, then reconcile2. is the call idempotent?retry with the same key3. neither?reconcile — never blind-retryif it is neither queryable nor idempotent, exactly-once is impossible from the caller alonethe choice becomes a business one: at-most-once and risk losing it, or at-least-once and risk doing it twice
A timeout is not a failure. It is an absence of information, and it must be treated as one.

The three cases, in order of preference

SituationWhat to do
A status API existsWait briefly, read the truth. If already done, reconcile and mark success. Otherwise poll with bounded exponential backoff and jitter — the provider may be eventually consistent.
No status API, but the call is idempotentRetry with the same idempotency key and bounded backoff.
NeitherDo not blindly retry. Persist as PENDING_RECONCILIATION, use provider reconciliation, and tell the user it was submitted but cannot currently be confirmed — never that it failed.

Crash timing windows during an external call

Crash pointWhat the provider saw
Before the request is sentNothing. Safe to retry.
Mid-transmissionMay receive an incomplete request and reject it — still uncertain from your side.
After they received it, before you crashThe connection drops but they may keep processing.
They completed it, you crashed before persistingExternal state is done, local state says IN_PROGRESS. The classic ambiguity.
precise wording matters here

If the client connection is cut, the third-party operation may continue if the provider already received the full request. Do not say it definitely will — that overstates what you can know.

02The decision tree

what actually went wrong?same logical request, again→ idempotency keyclaimed atomicallydifferent requests, one resource→ lock / conditional UPDATE+ a unique constraintcrash left IN_PROGRESS→ lease + atomic takeoverowner_id + expiryDB + external system→ outbox+ downstream dedupeeverything inside one database?→ one transaction: business writes + COMPLETED togetherthe distinction people get wrongidempotency answers “has this logical operation already happened?”locking answers “is someone else in this critical section right now?”they are not alternatives — a concurrency-safe idempotency claim needs both ideas
Five questions. Each one points at exactly one mechanism — and they are not interchangeable.

03Claim, lease, complete

1. claimINSERT … idempotency_keystatus = IN_PROGRESSowner_id, lease_expires_atON CONFLICT DO NOTHINGrow returned → I own it2. take overUPDATE … SET owner_id = meWHERE status = IN_PROGRESSAND lease_expires_at < NOW()1 row → took over · 0 rows → someone lives3. completeUPDATE … SET COMPLETEDWHERE idempotency_key = :kAND owner_id = :meonly the live owner may finish itwhy IN_PROGRESS alone is never enoughit cannot distinguish “a worker is doing this right now” from “a worker said that and then died”the lease is what turns an assertion into a claim with an expiryand for work longer than the lease, renew by heartbeat rather than guessing a bigger number
Three statements. Never SELECT-then-act — the gap between them is the bug.
-- claim: handles both concurrent duplicates and later retries
INSERT INTO idempotency_keys (idempotency_key, status, owner_id, lease_expires_at)
VALUES (:key, 'IN_PROGRESS', :worker, NOW() + INTERVAL '5 minutes')
ON CONFLICT DO NOTHING
RETURNING *;
-- row returned → I own execution.  no row → someone else owns or owned it.

-- take over, only from an owner that looks dead
UPDATE idempotency_keys
   SET owner_id = :worker, lease_expires_at = NOW() + INTERVAL '5 minutes'
 WHERE idempotency_key = :key
   AND status = 'IN_PROGRESS'
   AND lease_expires_at < NOW()
RETURNING *;

-- complete, only if I still own it
UPDATE idempotency_keys
   SET status = 'COMPLETED'
 WHERE idempotency_key = :key AND owner_id = :worker;
never SELECT then act

SELECT to check, then INSERT or UPDATE, leaves a gap where another request does the same thing. One atomic statement whose return value tells you whether you won is the entire mechanism.

When the work is replayable, keep it simple

GCS read + in-memory computation are safe to repeat.
Do not add step-level idempotency for them.

  fetch / read / compute        ← replayable, no bookkeeping
  BEGIN
    DB write A
    DB write B
    idempotency = COMPLETED     ← same transaction
  COMMIT

Crash before commit and the transaction rolls back, so the whole thing replays cleanly. Commit succeeds and business state plus completion state are durable together. One transaction is always the simplest correctness model available — reach for anything else only when the work genuinely spans systems.

04Idempotency vs locking vs constraints

MechanismQuestion it answersTypical use
IdempotencyHas this logical operation already been requested or executed?Network retries, Kafka redelivery, repeated API calls
Lock / atomic claimIs someone else executing this critical section right now?Concurrent booking, multiple recovery workers
Unique constraintCan the database ever persist two conflicting records?The final correctness guard, regardless of application bugs

The distinction is not “same time versus one after another”. Two duplicate requests can arrive simultaneously and still be handled by idempotency — provided the idempotency claim itself is concurrency-safe, which is what the unique constraint and atomic insert are for.

Request identity is not business identity

a user double-clicks Cancel; the client generates two request IDs

  request-level:   Idempotency-Key = abc123   vs   xyz789   ← different!
  business-level:  (policy_id = P123, operation = CANCEL)   ← the same

so you need both:
  UNIQUE(idempotency_key)               stops retry duplication
  UNIQUE(policy_id, operation_type)     stops duplicate user intent

Two users, one slot — a different problem entirely

-- this is concurrency, not idempotency: two distinct logical operations
UPDATE slots SET status = 'BOOKED'
 WHERE id = :slot_id AND status = 'AVAILABLE';

-- rows_updated = 1 → won.   0 → someone else took it.

A pessimistic alternative is SELECT … FOR UPDATE inside an explicit transaction. Either way, add a database uniqueness constraint as the last line of defence.

the autocommit trap

SELECT … FOR UPDATE in autocommit mode commits immediately and releases the lock before your later writes — the lock becomes decorative. It must run on the same connection, in the same explicit transaction, as the work it protects. And guard hung sessions with idle_in_transaction_session_timeout and a sane lock_timeout.

05When one key is not enough

one message, five non-atomic side effects, crash after step 31 update DBDONE2 call API ADONE3 publish eventDONE4 call API BPENDING5 write auditPENDINGcrashblindly skip all fivesteps 4 and 5 never happenblindly redo all fiveAPI A is called twiceresume from per-step state — and give each external step its own keyabc123:step2 · abc123:step3 · abc123:step4
One key says done-or-not. Five side effects need five answers.
{
  "operation_id": "abc123",
  "status": "IN_PROGRESS",
  "steps": {
    "update_db":      "DONE",
    "external_api_a": "DONE",
    "publish_event":  "DONE",
    "external_api_b": "PENDING",
    "audit":          "PENDING"
  }
}

step keys:  abc123:step2 · abc123:step3 · abc123:step4
the edge case inside the edge case

A step can succeed externally and the process can crash before marking it DONE. So every external, non-transactional step needs its own idempotency or reconciliation strategy — per-step state alone just moves the ambiguity down one level.

Writes inside one Postgres database should be one transaction. Once a workflow spans DB, Kafka and external APIs, you are into per-step state, idempotent operations, outbox, and compensation or saga where the business allows it.

06Redis or Postgres for idempotency state?

RedisPostgres
StrengthFast, natural TTL, good for short-lived dedupeDurable, transactional, unique constraints
RiskA recent key can be lost or evicted depending on config and failure modeHigher write cost and operational weight
Atomic with business state?No — Redis + Postgres is a cross-system writeYes — same transaction
Good fitHigh-throughput dedupe where some loss is tolerablePayments, refunds, anything financial or critical
the Redis + Postgres failure window

  Postgres business update succeeds
    → server crashes
    → Redis COMPLETED key never written
    → retry sees no key
    → duplicate work, unless something else protects it
the decision rule

Choose on acceptable data-loss risk, durability, atomicity with business state, and performance — not on the fact that idempotency keys expire. A short TTL does not make Redis the right store.

07Kafka and Cloud Tasks

Both are at-least-once. A message or task can be delivered again if an acknowledgement is lost or the handler fails, so the handler must turn repeated attempts into one logical outcome.

delivery attempt
  → claim / check the operation
  → execute and commit safely
  → mark COMPLETED
  → acknowledge
the Cloud Tasks gotcha

Any 2xx acknowledges the task — including 202 Accepted. If you return 202 meaning “I have started this”, Cloud Tasks deletes the task and will never retry it. Return 2xx only when the operation is durably complete, or already COMPLETED from a previous attempt.

08End-to-end: a cancellation

user requests cancellation, client supplies an idempotency key
        ↓
backend atomically creates the operation
  key = abc123, policy = P123, status = IN_PROGRESS
        ↓
call the provider
        ↓
   ┌────────────────┬──────────────────────┐
 SUCCESS                          timeout / crash
   │                                      │
mark COMPLETED                  stays IN_PROGRESS
                                          ↓
                                  recovery worker
                                          ↓
                            atomically claim recovery
                                          ↓
                                check provider status
                                  ↙               ↘
                            CANCELLED          ACTIVE / UNKNOWN
                                │                     ↓
                          mark SUCCESS         bounded polling
                                                      ↓
                                            still not cancelled
                                                      ↓
                                          idempotent retry possible?
                                             ↙              ↘
                                           YES               NO
                                    retry same op        reconcile

Multiple recovery workers

-- option 1: skip what someone else holds
SELECT * FROM idempotency_operations
 WHERE status = 'IN_PROGRESS'
   FOR UPDATE SKIP LOCKED;

-- option 2: atomic state transition
UPDATE idempotency_operations SET status = 'RECOVERING'
 WHERE id = :id AND status = 'IN_PROGRESS';
-- 1 row → I own recovery.  0 rows → already claimed.

09Question drill

An external cancellation API times out. What do you do?

Treat the outcome as UNKNOWN, not failed — the provider may have completed it and only the response was lost. Before the call, persist IN_PROGRESS with an idempotency key. On timeout, query the status API first; if cancelled, reconcile and mark success. Otherwise poll with bounded backoff and jitter. Retry only if the operation is idempotent, with the same key. If you cannot verify and retry is unsafe, move to reconciliation rather than risk a duplicate.

Why can't you just retry on timeout?

Because a non-idempotent side effect might already have happened — a duplicate refund, charge or notification. The HTTP outcome tells you nothing about the remote state; you have to establish that separately.

Two duplicate requests arrive at exactly the same moment.

Do not SELECT then INSERT. Use one atomic INSERT … ON CONFLICT DO NOTHING RETURNING * on a unique idempotency key. A returned row means you own execution; no row means someone else does or did. That single statement handles both simultaneous duplicates and later retries.

A worker crashed and the key is stuck at IN_PROGRESS forever.

IN_PROGRESS alone cannot distinguish a live worker from a dead one. Add owner_id and lease_expires_at. A valid lease means hands off; an expired lease can be taken over with one conditional UPDATE guarded by lease_expires_at < NOW().

Idempotency or locking — which do you need?

Idempotency answers “has this logical operation already happened?”. Locking answers “is someone else in this critical section right now?”. Repeated same request → idempotency. Different requests competing for one resource → concurrency control. And the idempotency claim itself must be concurrency-safe, so in practice you use both ideas.

Two users book the same slot. Is that idempotency?

No — those are two different logical operations, so it is a race, not a duplicate. Use a conditional UPDATE (WHERE status = 'AVAILABLE') or SELECT … FOR UPDATE, plus a unique constraint as the final guard.

The app crashes while holding SELECT … FOR UPDATE.

When the database detects the connection is gone, the transaction rolls back and the lock releases. The real risks are autocommit — where the lock was released immediately anyway — and a hung app that keeps the connection alive, which needs idle_in_transaction_session_timeout.

A consumer performs five side effects and crashes after three.

One operation-level key cannot express that. Track per-step state so recovery resumes from step four rather than skipping everything or redoing everything — and give each external step its own idempotency key, because a step can succeed externally before you record it.

Redis or Postgres for idempotency keys?

Postgres when the business state is in Postgres, because the idempotency record can commit in the same transaction and there is no cross-system window. Redis when throughput matters and some loss is tolerable. TTL is not the deciding factor — durability and atomicity are.

Your Cloud Tasks handler returns 202. What happens?

The task is acknowledged and deleted, because 202 is 2xx. If you needed a retry, that work is now silently dropped. Return 2xx only when the operation is durably complete or already COMPLETED.

Two recovery workers find the same stale operation.

Make ownership atomic: FOR UPDATE SKIP LOCKED, or a conditional UPDATE … SET status = 'RECOVERING' WHERE status = 'IN_PROGRESS' and check the affected row count. Whoever gets the row owns recovery.

The external API is neither idempotent nor queryable. Now what?

Exactly-once is impossible from the caller alone, and saying so is the correct answer. It becomes a business choice: at-most-once, never repeating an ambiguous call and accepting it may not have happened; or at-least-once, retrying and accepting it may happen twice. Pick based on which failure the business can absorb.

10Cheat sheet

TermOne line
IdempotencyRepeating the same logical operation creates no extra effect.
Concurrency controlOnly the rightful owner mutates shared state when executions overlap.
RetryRepeat an attempt when the failure is transient or safely repeatable.
RecoveryWork out what actually happened, then continue without corrupting state.
TransactionMake changes within one database atomic.
OutboxBridge a DB commit to an external asynchronous side effect.
LeaseDistinguish an active IN_PROGRESS owner from an abandoned one.
ReconciliationResolve outcomes local state cannot safely infer.
At-most-onceNever repeat an ambiguous call; may lose the operation.
At-least-onceRetry until success; the side effect must be idempotent.

Never make a retry decision from an HTTP status alone. Ask what side effects may already exist.

← all notes