Engineering Atlas › Problem Solving & Incidents (AI era)

One Problem, Five Domains: The Duplicate Request

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The same failure, five costumes

Payments. A checkout service calls the card processor, which charges the card and replies, but the reply times out at the load balancer. The service retries. Without protection the customer pays twice.

Ride-hailing. A rider taps "Request" on a flaky mobile connection. The app shows a spinner, times out and resends. Two trip requests reach dispatch, and two drivers are sent.

Chat. A phone sends a message, loses connectivity before the server's acknowledgement arrives, reconnects and resends from its outbox. The group sees the message twice.

E-commerce. A customer double-clicks "Place order", or refreshes the confirmation page, which re-submits the form. Two orders, two shipments.

AI agents. An agent calls a send_email tool, the tool call takes 40 seconds and the agent runtime's 30-second timeout fires. The agent, reasonably, tries again. The customer gets two emails, or worse, two refunds if the tool issues refunds.

Strip the nouns and every story is: a side-effecting request, an ambiguous failure, and a retry. The retry is also the receiver's problem when it is the sender, as with webhooks: Stripe says the same event may be delivered more than once and advises logging processed event IDs (Stripe webhooks).

The mechanism

The sender creates a unique key for the intent, not for the attempt: one key per checkout, per trip request, per message, per order, per tool call. It sends the key with every retry. The receiver, before acting, looks the key up:

What the domain decides

Who makes the key. Payment APIs ask the client to send an Idempotency-Key header (Stripe accepts keys up to 255 characters and suggests random V4 UUIDs, or a key derived from something like a cart ID; for v1 it saves the first response after execution begins, including 500 errors). Chat apps generate a client message ID when the message is composed. E-commerce forms embed a token when the checkout page renders. Agent runtimes should derive the key from the task ID and the tool call's position in the plan, so a replayed plan reuses it.

How long to remember. Long enough to cover every retry: Stripe's v1 API keeps keys for at least 24 hours and may prune them after that, then treats a reused key as a new request, so its guidance is to stop retrying after 24 hours and reconcile instead; its v2 API uses a 30-day window (Stripe API docs, Stripe error handling); a chat server needs keys only for the outbox's retry window, perhaps minutes; a payments ledger may keep business keys forever.

What a duplicate costs. A repeated chat message is embarrassing; a repeated refund is a direct loss. Cost decides whether the key store can be a cache that might forget (chat) or must be durable and transactional with the effect (money).

Down the stack

Layer 1The contract

The receiver promises that for a given key and identical parameters, the side effect happens at most once and every attempt gets the same response. It promises that a concurrent duplicate never runs in parallel with the original, and that a key reused with different parameters is rejected. It states how long keys are retained, after which a repeat is treated as new.

Layer 2The mechanism

A durable implementation keeps a table such as idempotency_keys(key PRIMARY KEY, fingerprint, state, response, created_at). The first attempt runs INSERT ... ON CONFLICT (key) DO NOTHING in the same transaction as the business write; if the insert affected zero rows, it reads the existing row and acts on its state. The fingerprint is a hash of the normalised request parameters, for example SHA-256 over sorted JSON, so a reused key with a different amount is detected. When the effect is in another system, such as a card network, the key is passed downstream too, and the in-progress state is recovered by asking the downstream system what happened (a status query) rather than guessing.

Layer 3The machine

The guarantee rests on the database's unique index. Two concurrent inserts of the same key both descend the B-tree to the same leaf page; the first takes the page lock and inserts, the second finds the uncommitted entry and blocks until the first transaction commits or aborts, then sees a conflict or proceeds. On the wire, the retry is just another HTTP request: TCP's own sequence numbers already deduplicate retransmitted segments within one connection, but a retry after a timeout opens a new connection, so the application must do the same job one level up. The cost is one indexed lookup, typically under a millisecond, which is why there is rarely a reason to skip it. Kafka applies the same idea inside the broker: an idempotent producer carries a producer ID and sequence numbers so a resent batch is not appended twice (Kafka design docs).

Why it's built this way

  1. Networks can lose replies, so a sender cannot distinguish "not done" from "done but unheard".
  2. Not retrying loses work, so senders retry.
  3. Retrying repeats side effects unless the receiver can recognise the repeat.
  4. Recognition needs an identity for the intent that survives across attempts: the key.
  5. The key must be recorded atomically with the effect, so the store lives next to the effect, in the same transaction where possible.

Diagram

sequenceDiagram
  participant C as Client or agent
  participant S as Service
  participant K as Key store
  C->>S: request (key k1)
  S->>K: insert k1 in progress
  S->>S: do side effect
  S-->>C: reply lost (timeout)
  C->>S: retry (key k1)
  S->>K: lookup k1
  K-->>S: done, stored response
  S-->>C: same response, no new effect

Worked example

A company runs a support agent that can issue refunds through a refund(order_id, amount) tool. It handles 30,000 conversations a day, and the tool backend's p99 latency is 25 seconds because it calls a slow payment provider. The agent runtime's tool timeout is 30 seconds and it retries once. Logs show about 1.5 percent of refund calls exceed 30 seconds. Without idempotency, about 450 of 30,000 daily refunds are attempted twice; at an average of 40 dollars, that is up to 18,000 dollars a day issued in error.

The fix has three parts. The runtime derives the key as task_id:step_number, so a retry of the same step reuses it. The refund service stores the key and a fingerprint of (order_id, amount) in the same transaction as the refund record, and passes the key to the payment provider as its idempotency key. A retry that arrives while the first is still in progress gets "in progress, retry later" and the agent waits. The double-refund rate drops to zero, and the remaining risk, a key reused with different parameters, raises an alert instead of a silent refund.

Exactly the same three parts would fix a checkout page, a ride request or a chat outbox, which is the lesson.

In the wild

Trade-offs

Key storeStrengthsWeaknessesChoose when
Same database transaction as the effectExact; no gap between record and effectTies key storage to the business databaseMoney, inventory, anything costly
Separate cache (e.g. Redis with TTL)Fast, simple, cheapEffect and record can diverge; cache can evictLow-cost duplicates such as chat messages
Natural business key (order number)No extra infrastructureNeeds a natural unique identifierEntities that already have one
Downstream provider's idempotencyCovers the external effectYou still need your own recordAny call to a payment or messaging provider

Pitfalls in production

Leadership lens

Check yourself

  1. Why must the key identify the intent rather than the attempt?

    Show answer

    The receiver uses it to recognise repeats; if each attempt has a new key, every retry looks like a new request and the side effect repeats.

  2. What should happen when a duplicate arrives while the original is still being processed?

    Show answer

    It should not run in parallel; the receiver returns an in-progress or conflict response and the client retries later to get the final stored result.

  3. Why store a fingerprint of the parameters with the key?

    Show answer

    To detect a client reusing a key for a different request, which should be rejected rather than answered with an unrelated stored result.

  4. When is a cache with a TTL an acceptable key store?

    Show answer

    When a rare duplicate is cheap, as with a chat message, because a cache can evict keys or diverge from the effect.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →