Idempotency and retries

One chat request, repeated worker attempts, and the reply we actually save. Derive the safeguards from a failure, then decide which failures deserve another attempt.

System design deep diveVideo + written guideBy Daniyal Saqib

Follows When to add a queue Same application, a different design question.

Watch the walkthrough, then follow the written guide below.Watch on YouTube
Learning depth

Follow one user question through several possible worker attempts. Keep the saved reply stable, recover abandoned work, and make an explicit decision when a timeout leaves the model provider’s outcome unknown.

Complete Mid article · 11 sections.
01

The user presses Send once

One requested reply must not become extra saved replies when recovery repeats work.

The PDF from the queue guide is now searchable. The user asks, “What are the key decisions in this document?” and presses Send once. We create reply task A7: produce one response to that message. The browser may disconnect and a worker may crash, but those failures do not mean the user asked another question.

For this constructed example, the work runs in the background and the completed answer is saved before the chat displays it. Background execution lets accepted work outlive the connection; it is not a requirement for every chat product. We are protecting the stored reply, rather than making model output deterministic or proving its answer correct.

Name the promise precisely: at most one saved response per reply task. That leaves room for no response if every allowed attempt fails, and for repeated model computation during recovery. A user who intentionally asks for another answer creates new logical work. Those distinctions tell us which identities and safeguards the design needs.

02

Follow one successful reply

A task is the work owed to the user; an attempt is one effort to finish it.

The API saves the user message, creates A7 and records a recoverable queue handoff. As in the upload guide, accepted work cannot depend only on an API process remembering to publish. The dispatcher can publish again after a failure, but every delivery still identifies A7.

Full Excalidraw-style flow from the chat client and API through a reply-task queue, worker, LLM provider, reply store, and the saved assistant reply.Full Excalidraw-style flow from the chat client and API through a reply-task queue, worker, LLM provider, reply store, and the saved assistant reply.
fig 02 / From one user message to one saved reply. Original video diagram. Open the image at full size; scroll horizontally on smaller screens.

A worker receives the task and calls the LLM provider. The returned text is a candidate answer. It becomes the saved response only when our database accepts it. Saving the response and marking A7 complete happen together, so a reconnecting browser cannot observe “complete” without the corresponding reply.

The worker acknowledges queue progress after that durable save. The browser can fetch the saved response later; queue acknowledgment does not wait for the screen to display it. Keep user message, task, delivery, attempt, candidate answer and saved response distinct. We can now move a crash between these steps and identify the missing evidence without changing what the user requested.

03

Why the same work can return

Receiving a delivery and recording successful completion are separate events.

Suppose the provider returns text, then the worker crashes before saving it. A7 is still unfinished, so recovery tries again. The provider may do expensive work twice even though only one task exists. A queue can preserve unfinished work without preventing that repeated computation.

A crashed worker and a replacement both call the model for the same reply task. Separate alternative panels show SQS visibility recovery and Kafka retained records with committed group progress.A crashed worker and a replacement both call the model for the same reply task. Separate alternative panels show SQS visibility recovery and Kafka retained records with committed group progress.
fig 03 / Two different ways to encounter the same work again. Original video diagram. Open the image at full size; scroll horizontally on smaller screens.

With SQS, receiving a message normally hides it for a visibility period. Successful processing is followed by deletion. If it is not deleted before the period expires, another worker can receive it. A worker can extend the period, but Standard queues can still deliver duplicates during visibility. Treat that timer as delivery coordination, not an exclusive correctness lock.

With a traditional Kafka consumer group, reading does not remove a record. The group saves a restart position. If its committed position is 41, it can read record 41 again after a crash. Once that record and all earlier work are durably handled, committing 42 records where to resume. Never advance progress past unfinished work merely because a later attempt finished first.

04

Protect the reply we save

The check for an absent response and the accepted save must be one atomic decision.

Two workers can both return candidate text. If each independently reads “no saved response” and later appends its answer, both may succeed. Move the decision into the authoritative database operation: accept a candidate only when the task still has no response and the worker satisfies the completion rule developed below.

Two model attempts return potentially different answers. An atomic save gate accepts one response, keeps it unchanged, and the user chat shows that saved response.Two model attempts return potentially different answers. An atomic save gate accepts one response, keeps it unchanged, and the user chat shows that saved response.
fig 04 / Repeated attempts, one saved response. Original video diagram. Open the image at full size; scroll horizontally on smaller screens.

Once a candidate is accepted, its response and completed state commit together. Repeating completion leaves that saved answer unchanged. The chat reads this accepted result rather than displaying every attempt that returns. Different model text is compatible with this behavior because idempotency is about the saved effect at this boundary.

This does not guarantee an answer exists or refund duplicate model work. It also does not undo text already streamed to the user. A streaming product needs an accepted stream, attempt-aware events and reconnect rules. Our simpler save-before-display contract avoids exposing unaccepted candidates while we establish the local completion mechanism.

05

Recognize a retry and allow Regenerate

Stable identity follows the user’s intention, rather than identical prompt text.

The browser creates a submission ID for one press of Send and reuses it when that submission must be retried. The API scopes the key to the authenticated caller or workspace and operation. The first accepted submission creates one message and A7; a repeated submission resolves to those existing records and their status.

Tie the key to the original conversation, document version and other relevant inputs. Reusing it with changed inputs is an error, rather than permission to mutate the old task. Retain the record for the supported retry and replay window. Deleting it too soon can make a delayed duplicate look like new work.

Regenerate is different: the user deliberately requests another answer, so it receives a new task even if the words are identical. Prompt hashing cannot reliably express that intention. Give executions separate attempt IDs for tracing, while keeping A7 stable across its retries. Submission deduplication prevents new tasks; it does not prevent repeated deliveries of the existing one.

06

Reduce avoidable overlapping calls

A renewable atomic claim selects a worker without making abandoned work permanent.

A worker first checks whether A7 is already complete. If so, it skips the provider call. Otherwise it asks the shared database to claim the available task in one atomic operation. Two workers cannot both win the same current claim; the loser avoids an unnecessary call.

Two workers receive the same reply task. A shared database assigns worker A, which calls the provider, while worker B does not call.Two workers receive the same reply task. A shared database assigns worker A, which calls the provider, while worker B does not call.
fig 06 / Choose one worker before calling the model. Original video diagram. Open the image at full size; scroll horizontally on smaller screens.

A permanent running flag would strand the task after a crash. Instead, the claim has a renewable time limit, called a lease. A live worker renews it while making progress. When it expires, a replacement can take over under the retry policy. Keep database operations short instead of holding a transaction open throughout the provider call.

The lease is separate from SQS visibility, and neither can cancel a remote model call already in progress. A duplicate that sees an active claim should defer or rely on a durable scheduler that will revisit unfinished tasks. It must not remove the only recovery path simply because another worker appears active. Completed work can safely be acknowledged because its result is already durable.

07

A timeout leaves several possibilities

No answer received does not prove that the provider did no work.

A request leaves the worker, then its connection fails. Perhaps the provider never received it. Perhaps generation is still running. Perhaps it finished and the returned answer was lost. All can look like “no result” in the local task record, and a new lease does not settle which occurred.

An earlier model attempt has an unknown outcome after a lost connection. The assignment expires and a new worker retries under the example’s explicit recovery policy.An earlier model attempt has an unknown outcome after a lost connection. The assignment expires and a new worker retries under the example’s explicit recovery policy.
fig 07 / Recovery with an unknown earlier outcome. Original video diagram. Open the image at full size; scroll horizontally on smaller screens.

First check for a documented recovery contract at that endpoint: suitable idempotency support or durable lookup using an operation identity already retained. A request ID useful for support logs does not automatically permit result recovery. Payload rules, key scope, retention and concurrent-call behavior determine what a provider contract actually protects.

If recovery is unavailable, choose a policy. In this example, a bounded retry is worth the possible duplicate computation because the user still needs a reply. Another product may stop and reconcile instead. Neither choice follows automatically from local idempotency; one controls the risk of a missing result and the other the risk of a repeated remote effect.

08

Reject a stale worker after takeover

A worker needs the current valid assignment when it saves its answer.

Worker A claims A7, then loses contact without dying. Its lease expires and worker B takes over. A later returns with text while the response slot is still empty. Checking only “no answer yet” would let the old assignment win, even though the recovery protocol selected B.

An answer from worker A’s old assignment is rejected. Worker B’s current assignment saves its answer and completes the reply task. Repeated completion preserves that response.An answer from worker A’s old assignment is rejected. Worker B’s current assignment saves its answer and completes the reply task. Repeated completion preserves that response.
fig 08 / Reject an old assignment before saving. Original video diagram. Open the image at full size; scroll horizontally on smaller screens.
Illustrative PostgreSQL completion transaction; one connection, named parameters supplied by the worker
BEGIN;

SELECT task_id
FROM reply_tasks
WHERE task_id = :reply_task_id
FOR UPDATE;

UPDATE reply_tasks
SET saved_response = :candidate_answer,
    status = 'completed'
WHERE task_id = :reply_task_id
  AND assignment_version = :my_assignment_version
  AND status = 'running'
  AND lease_until > clock_timestamp()
  AND saved_response IS NULL
RETURNING task_id;

COMMIT;

Each new assignment receives an atomically increasing version. The worker remembers its version. The database accepts completion only if that version is still current, the assignment remains valid, the task is running and no response has been saved. Saving the answer and completing the task still commit together. The old version cannot authorize a new write after takeover; this is fencing.

The completion transaction first locks the task row. On the same connection, its guarded update checks the lease against the database’s changing clock_timestamp(), then saves and completes while that lock is held. This defines lease validity at completion authorization; commit may follow later, while a takeover waits for the lock. CURRENT_TIMESTAMP records transaction-start time and would not provide a fresh expiry check after lock waiting. Keep this transaction short and give claims and renewals equivalent ownership and time checks.

If the completion operation reports no accepted row, read durable state rather than generate again blindly. Another worker may have completed, or ownership may have expired. If a save succeeded but its acknowledgment was lost, the saved task resolves that uncertainty. If it succeeded before a queue-acknowledgment crash, the replacement observes completion and skips another model call.

09

Decide whether another attempt helps

Safe repetition still needs a failure-aware stopping policy.

Separate failures by what is known. If the provider was definitely not called, an eligible worker may try the same task. A documented retryable response follows the endpoint’s retry rules. An unknown outcome needs recovery or the explicit duplicate-cost policy. Invalid input or another terminal failure should become a clear failure instead of unchanged resubmission.

A failure-to-action decision table
What we knowWhat we doWhat still needs a limit
The provider was definitely not called.Let an eligible worker claim the same task and try.The request’s deadline and total attempts.
The provider reports a documented retryable failure.Follow that contract, including any retry delay.Wait between attempts; stop at the retry budget.
We do not know whether it completed.Recover or reconcile first, if supported. Otherwise apply the duplicate-cost versus missing-answer policy.Reconciliation time, duplicate spend, and user wait.
The input is invalid or the failure is terminal.Record a clear failure and stop resending unchanged work.Require corrected input or an intentional new request.

For allowed retries, backoff increases the wait between attempts and jitter varies it so workers do not all retry together. Limit total attempts or elapsed time, and respect cancellation and the task deadline. Backoff without a stopping rule can keep failing work alive indefinitely; waiting does not make invalid input valid.

Count retries from the browser, API, worker and provider SDK together. Repeated layers can multiply remote calls. Queue redelivery must not reset A7’s budget. If repeatedly failing work is moved to a dead-letter queue, record a visible task outcome and preserve identity for deliberate replay. Moving a message aside does not tell the waiting user what happened.

10

Match protection to the effect

Submission keys, worker claims and saved-response guards protect different boundaries.

A submission key prevents a repeated HTTP request from creating another task. A renewable claim reduces simultaneous work on an existing task. An atomic completion gate protects the accepted stored response. We use them together because none covers the others’ whole responsibility.

Where each option helps, and where it stops
OptionUseful whenBoundary or tradeoff
Deduplicate before enqueueing.The same client submission arrives again.Does not prevent later redelivery or repeated processing of the existing task.
Assign one worker with a renewable claim.Overlapping work is expensive.Recovery after expiration can overlap an earlier external call.
Allow repeated computation; save once.Only one stored result matters, and extra computation is tolerable.Protects the local result, not provider cost or already-streamed text.
Commit the effect and progress together.Both belong to the same transactional store.A local transaction cannot ordinarily include a separate LLM service.
Use downstream idempotency or durable lookup.The effect owner provides a suitable contract.Guarantees depend on that endpoint and its retention and replay rules.
Stop after an ambiguous outcome.Another possible effect is worse than a missing result.May need reconciliation or user action to resolve unfinished work.
Tolerate or compensate for repetition.The repeated effect is harmless, or an explicit repair is acceptable.Repair has cost and may be incomplete. Discarding text does not refund model work.

If a consumed record’s local database effect and restart position are stored in one database, one transaction can sometimes cover both. That does not bring a separate LLM service into the transaction. Downstream idempotency or durable operation lookup can help only where the owner of that effect provides a suitable contract.

Sometimes repeated computation is acceptable while repeated publication is not. That is our choice here: attempts may produce different candidates and cost money, but A7 has at most one saved reply. A payment, notification or other external action would need its own protection. Always name the effect before calling processing exactly once.

11

Apply the framework to document ingestion

Carry the identity, effect and uncertainty questions to the next workload.

For ingestion, logical work might mean “index this document version at this stage.” A retry keeps that identity; uploading a new version creates new work. The harmful repeat could be duplicate index entries, while a late older version becoming current is another effect to prevent. The details differ from a saved chat response, but the boundary questions remain useful.

Expected behaviors to verify in an implementation
Inject this eventExpected result
Submit the same request ID concurrently.One user message and reply task; both submissions resolve to it.
Deliver the task to two workers together.One valid claim; the other worker makes no provider call.
Crash after claiming but before calling.The unfinished task remains recoverable after the assignment expires.
Lose the connection after the provider receives the call.Apply the unknown-outcome policy; do not infer that no work happened.
Save the answer, then crash before queue progress is recorded.Repeated processing finds completion and makes no new model call.
Return the old worker after a takeover, before the new worker finishes.Reject the old assignment even while the response slot is empty.
Send competing completion writes.One accepted response; answer and completion cannot be partially committed.
Press Regenerate with the same prompt.A new task is allowed. It is not mistaken for a retry.

Follow the crash around the durable effect. Before publication, unfinished work must remain discoverable. After publication but before queue progress, redelivery must recognize completion or repeat safely. A replacement must not mistake its empty local cache for evidence that the task never ran, and a stale worker must not overwrite a newer accepted result.

These are expected behaviors to verify in the actual implementation. A diagram demonstrates the intended protocol; it is not proof of a tested service. Explain the authoritative state behind each outcome and name the policy for an unknown external result. That produces a transferable design instead of a list of queue and database features.

Comments

Drag IL Pet to any screen edge. Use Shift with an arrow key to change edges, arrow keys to move along an edge, and Home to reset.