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

Keep one task distinct from its attempts, save one reply, and handle duplicate work, uncertain outcomes, and late workers safely.

11 sections in this guide.
01 / The user presses Send once

The user presses Send once

Recovery should not turn one user request into replies the user never asked for.

In the previous deep dive, a user uploaded a document. We asked whether making it searchable had to finish inside the upload request, and derived a queue-backed path from that requirement. Now the document is ready. The user opens the same product, types “What are the key decisions in this document?”, and presses Send.

From their point of view, there is one question waiting for an answer. Behind the screen, a worker might crash, a connection might time out, or a message might be processed again. The new question is: if answering that question takes more than one attempt, what stops those attempts from creating extra replies?

We will use a background reply task so that accepted work can outlive the browser connection. That is a choice for this example, not a claim that every chat request needs a queue. The same failure reasoning also applies when the request is handled directly.

02 / Follow one successful reply

Follow one successful reply

The reply task is the work we owe the user. A model attempt is one effort to complete it.

The chat API saves the user’s message and creates a reply task: produce one answer to this message. It also records a recoverable handoff to the queue, so a crash after accepting the request cannot quietly lose the work. A dispatcher may publish that handoff more than once; each message still points to the same task.

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 application accepts it. In this design, saving that response and marking the task complete happen together in one database operation.

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.

Only after that durable save does the worker report success to the messaging system. The browser can fetch the saved response, reconnect to it later, or receive a notification that it is ready. Queue progress does not wait for the user’s screen to display it.

These names matter because one reply task can have several model attempts. We are not treating several provider calls as one physical call. We are keeping them attached to one piece of work and deciding which result, if any, becomes the reply.

03 / Why can another worker pick it up?

Why can another worker pick it up?

Receiving work and recording that it finished are different events. A failure between them can cause repeated processing.

Suppose the provider produces an answer, but the worker crashes before saving it. Another worker later encounters the unfinished task and tries again. The provider may now have done the expensive work twice. This can happen with SQS or Kafka, but their recovery mechanisms are different.

SQS Standard: a message is hidden, not removed

Receiving an SQS message starts its visibility timeout. For that period, the message is normally hidden from other workers. If the worker finishes, it deletes the message. If it does not delete it before the timeout expires, the message becomes available again. A long-running worker can extend the timeout. Thirty seconds is an example window, not a recommended duration for every model call. SQS visibility timeout documentation.

That helps avoid overlapping work, but it is not a correctness lock: SQS Standard can still deliver duplicates, including during the visibility window. The application must tolerate repeated processing. SQS at-least-once delivery documentation.

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.

Kafka: the record stays; the group saves its place

In a traditional Kafka consumer group, each partition is assigned to one consumer in that group at a time. Reading a record does not remove it. A committed offset records where the group should resume. For this example, automatic commits are disabled and we commit only after durable processing.

Imagine the saved restart position is 41. The worker reads record 41, advancing its local read position to 42, then crashes before saving the reply or committing progress. After recovery or reassignment, processing resumes from 41. After successfully handling that record, with earlier records also handled, the consumer commits 42. The commit does not delete record 41.

That is rereading from saved progress, not a per-message visibility timeout. With concurrent processing, do not commit past unfinished records in the same partition. Kafka transactions also do not automatically include an external LLM call. Kafka consumer positions and manual offset control.

04 / Idempotency protects the response we save

Idempotency protects the response we save

For the same reply task, repeated completion attempts must not create or replace an additional saved response.

Start with the simplest protection. When a candidate answer arrives, the database saves it only if this task has no saved response yet. The check and the save must be atomic: the database makes them one indivisible decision. Two workers must not both read “empty” and then independently append their answers.

The first successful save fills the task’s response slot and completes the task. A later attempt finds that the reply already exists and leaves it unchanged. The chat displays that saved response, rather than appending every candidate answer it receives.

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.

This is the idempotent behavior we need at this boundary. It does not mean the model returns identical text, the provider is called exactly once, or an answer is guaranteed to exist. All attempts could fail. The safety promise is at most one saved response per reply task; retry and recovery policies improve the chance of completing it.

05 / Recognize a retry without blocking a new request

Recognize a retry without blocking a new request

Identity follows the user’s intention, not just the words in the prompt.

The browser creates a request ID for this press of Send and reuses it if submission must be retried. The API uses a unique key scoped to the authenticated caller or workspace and operation. Repeating the submission returns the existing reply task and its status, rather than creating another message and task.

Bind that key to the original inputs, including the relevant conversation and document version. Reject reuse with different inputs instead of silently answering a different question. Keep the deduplication record for the supported retry and replay window; expiring it too early makes old work look new. These are application choices based on an explicit idempotent API contract.

But if the user presses Regenerate, they are asking for another answer. That creates a new reply task, even if the prompt is identical. Hashing the prompt alone would confuse intentional regeneration with a network retry. An attempt ID may help trace each execution, but it must not replace the stable task identity.

So, do we avoid enqueuing the same request twice? We avoid creating new logical work for a repeated submission. We still design for repeated queue messages and worker attempts for the existing task. Request deduplication and worker idempotency solve different parts of the problem.

06 / Can we stop the extra model call earlier?

Can we stop the extra model call earlier?

An atomic worker claim prevents avoidable overlapping calls, while a time limit lets abandoned work recover.

Before calling the model, a worker checks the shared task record. If the reply is already complete, it returns without another model call. If the task is available, it asks the database to assign it the work. Checking availability and recording the assignment happen together. Only the worker whose claim succeeds proceeds.

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 is not enough. If the worker crashes, nobody could take over. Instead, the assignment has a renewable time limit, often called a lease. The worker extends it while making progress. After it expires, recovery can assign the task to another worker, subject to the retry policy.

This database assignment is separate from SQS visibility. One coordinates our right to work on a task; the other controls delivery of a queue message. Neither timer can cancel a provider call that is already running. Do not hold a database transaction open while waiting for the LLM; keep claims, renewals, and completion writes short.

07 / A timeout does not tell us what happened

A timeout does not tell us what happened

Losing the connection proves that we lack an answer, not that the provider did no work.

The worker sends the request. The connection then fails. Maybe the provider never received it. Maybe it is still generating. Maybe it finished, charged for the work, and the answer was lost on the return trip. Locally, all three can look like “no answer received.”

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 ask whether the provider offers a documented way to recover the existing operation: a stable idempotency key with defined behavior, or durable lookup by an operation ID we already saved. Verify the relevant endpoint, retention window, payload rules, and concurrent-request behavior. A request ID that only helps support find logs is not automatically either guarantee.

If recovery is unavailable, there is a real product decision. Retry and accept the possibility of duplicate model work, or stop automatic recovery and risk leaving the user without an answer. In this chat example, we choose a bounded retry because completing the reply is worth some duplicate computation. That is a policy, not a property of idempotency.

Another product may make the opposite choice. Even then, “we will not automatically resend an ambiguous request” is narrower than promising exactly one physical provider execution. Client libraries and downstream services may have their own retry behavior.

08 / What if the original worker comes back?

What if the original worker comes back?

After a takeover, the old worker must not be able to save an answer using its expired assignment.

A worker can lose contact without dying. While it is delayed, its assignment expires and another worker takes over. The old worker later returns with an answer. An empty response slot alone would let that old worker win, even though our recovery process has already chosen a replacement.

Give each new assignment an increasing version number. The worker remembers the version it received. Before accepting its answer, the database checks that this version is still current, the assignment is valid, and there is no saved response. This is often called fencing: an old assignment cannot authorize a new write.

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;

Acquire the task-row lock first, then check the lease against clock_timestamp() in the guarded update on the same connection and transaction. This authorizes completion while holding the lock; a takeover must wait. The transaction may commit after that check. CURRENT_TIMESTAMP instead reports transaction-start time, so it cannot establish a fresh expiry check after waiting for a lock. Keep this transaction short and use equivalent locking and time semantics for claims and renewals. PostgreSQL time functions, row locks.

One returned row means the answer was accepted. Zero rows means it was not: read the task’s current state to distinguish a saved reply, an expired assignment, or a takeover. In PostgreSQL’s Read Committed mode, a competing update waits and rechecks its condition against the updated row. This simple single-row example relies on that behavior; use equivalent guarantees in your chosen database. PostgreSQL concurrent update semantics.

The assignment version must increase atomically on every claim or takeover, and renewals must check the same version. If responses live in another table, use a unique task key and put the accepted response and completion update in one transaction. A separate unconditional write would reopen the race.

What if this save succeeds, but its acknowledgement is lost? Read the durable task before deciding to generate again. What if the worker crashes after saving but before reporting queue progress? The next worker finds the completed task and skips the model call.

Rejecting old assignments is the design chosen here. A different product could accept the first valid answer from any attempt for immutable inputs. That can reduce wasted results, but it is a different acceptance rule. Do not accidentally mix that policy with “only the current worker may complete.”

09 / Decide whether another attempt is useful

Decide whether another attempt is useful

Idempotency keeps repeated attempts from corrupting the result. Retry policy decides whether, when, and how long to try again.

“Retry with backoff” is not enough by itself. Start with what we know about the failure, then choose an action. These are alternative paths, not four retries every task should perform.

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 retries we choose to allow, backoff means increasing the delay between attempts. Jitter means varying it so workers do not all retry together. Add a maximum attempt count or elapsed-time budget, and respect cancellation and deadlines. Do not assume every server error means nothing happened remotely.

Account for retries across layers: browser, API, queue worker, and provider SDK. Letting every layer independently retry several times can multiply calls. The task record should make the overall budget and terminal result visible, rather than letting redelivery reset the allowance.

10 / Choose the protection that fits the effect

Choose the protection that fits the effect

There is no single mechanism that prevents every duplicate across a database, queue, and external provider.

For this example, stable task identity, a short worker claim, and a guarded completion write work together. They reduce avoidable calls and protect the saved reply. But system design also means knowing the other options and what each gives up.

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.

For example, if both a local database change and the consumer’s restart position are stored in the same database, one transaction can cover them. That does not bring the remote model execution into the transaction. Always say which effect is protected before saying “exactly once.”

And yes, there are times when we accept something that is not idempotent. An LLM may produce different candidate text on each attempt, and we may pay for repeated computation. We tolerate that here while protecting the reply the product saves. A separate external action, such as sending a notification or charging a customer, needs its own effect-specific contract; this response guard does not cover it.

11 / Same framework, different work

Same framework, different work

Carry the questions to the next system, not just the component names.

Return to document ingestion. The logical work might be “index this version of this document,” with the processing stage included in its identity. Retrying that task should not add duplicate index entries. A new document version is new work, and a late worker must not make an older version current over a newer one.

The data model and storage checks will differ from chat. The reasoning stays the same: what counts as the same work, which repeated effect would hurt, where can we enforce a decision atomically, what can the downstream system recover, and which is worse when we are uncertain: another effect or no result?

Test the failure boundaries

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.

These are assertions to test against your implementation. The diagrams illustrate the expected outcomes; they are not evidence that a real queue-worker service has passed these tests.

Checkpoint

A worker saves the answer and crashes before deleting its SQS message. Later, another worker receives the message. What should it do, and which piece of state makes that safe?

Compare your reasoning

Read the durable reply task. Because its response and completed state were saved together, the second worker skips generation and deletes its delivery. The saved task is the evidence of completion, not whether the first worker stayed alive or whether the queue message disappeared.

Now move the crash earlier, before the save. A missing local response does not prove the provider did no work. That case needs the unknown-outcome policy, not the same conclusion.

Technical references

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.

Follow the next connection.

New deep dives, straight to your inbox.

Get new free resources as they’re published.

New resources only. Unsubscribe anytime.