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.
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.
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
The three cases, in order of preference
| Situation | What to do |
|---|---|
| A status API exists | Wait 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 idempotent | Retry with the same idempotency key and bounded backoff. |
| Neither | Do 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 point | What the provider saw |
|---|---|
| Before the request is sent | Nothing. Safe to retry. |
| Mid-transmission | May receive an incomplete request and reject it — still uncertain from your side. |
| After they received it, before you crash | The connection drops but they may keep processing. |
| They completed it, you crashed before persisting | External state is done, local state says IN_PROGRESS. The classic ambiguity. |
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
03Claim, lease, complete
-- 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;
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
COMMITCrash 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
| Mechanism | Question it answers | Typical use |
|---|---|---|
| Idempotency | Has this logical operation already been requested or executed? | Network retries, Kafka redelivery, repeated API calls |
| Lock / atomic claim | Is someone else executing this critical section right now? | Concurrent booking, multiple recovery workers |
| Unique constraint | Can 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.
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
{
"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:step4A 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?
| Redis | Postgres | |
|---|---|---|
| Strength | Fast, natural TTL, good for short-lived dedupe | Durable, transactional, unique constraints |
| Risk | A recent key can be lost or evicted depending on config and failure mode | Higher write cost and operational weight |
| Atomic with business state? | No — Redis + Postgres is a cross-system write | Yes — same transaction |
| Good fit | High-throughput dedupe where some loss is tolerable | Payments, 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 itChoose 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
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 reconcileMultiple 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
| Term | One line |
|---|---|
| Idempotency | Repeating the same logical operation creates no extra effect. |
| Concurrency control | Only the rightful owner mutates shared state when executions overlap. |
| Retry | Repeat an attempt when the failure is transient or safely repeatable. |
| Recovery | Work out what actually happened, then continue without corrupting state. |
| Transaction | Make changes within one database atomic. |
| Outbox | Bridge a DB commit to an external asynchronous side effect. |
| Lease | Distinguish an active IN_PROGRESS owner from an abandoned one. |
| Reconciliation | Resolve outcomes local state cannot safely infer. |
| At-most-once | Never repeat an ambiguous call; may lose the operation. |
| At-least-once | Retry 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