Launch offer: the first 5 clients receive a free 2-hour virtual consultation.·3 client spots open this quarter.
← All posts

Stop Your AI Agent From Repeating an Action After a Timeout

AI agentsReliabilityEngineering

Say you have an agent with a create_ticket tool. It calls the tool, the request hangs, and the call times out. The agent cannot tell whether the ticket was created and the response got lost, or whether the request never arrived. So it does what a reasonable operator would do and tries again. Now there are two tickets. Swap in a booking or a payment and the cost goes up fast.

This is a tool-layer problem, and it has a standard answer from payments APIs: make the write safe to repeat. Prompting the model with “do not retry” does not work, because the retry often comes from the framework, the HTTP client or the model’s own recovery loop, and none of them know what happened on the server.

The timeout is genuinely ambiguous

A timeout tells you only that you did not hear back in time. The server may have done nothing, or finished the job, or it may still be working on it. A client that cannot distinguish those states has two options, and both are bad: retry and risk a duplicate, or give up and risk a missed action.

An agent can make this worse than a normal client. A person sees “request failed” and checks before clicking again. An agent sees an error string and may treat it as a prompt to try something else, sometimes with slightly different arguments, which defeats any duplicate check based on exact payload matching.

Use idempotency keys, and create them in the right place

The Stripe API documentation describes the pattern most teams copy. The client generates a unique key and sends it as an Idempotency-Key header. The server records the result of the first request under that key, and a retry with the same key returns the saved response instead of running the operation again. Keys can be up to 255 characters, and for API v1 Stripe says they may be pruned after 24 hours. Stripe also compares the incoming parameters with the original request and returns an error if they differ.

There is now a draft standard for the same idea: draft-ietf-httpapi-idempotency-key-header (revision 08, updated 2026-10-22, so still a draft). It covers POST and PATCH, and it names the error cases: a missing key gets a 400, a reused key with a different payload gets a 422, and a duplicate that arrives while the original is still running gets a 409.

The easy mistake is in where the key comes from. If the model generates a fresh random UUID on every tool call, every retry is a new request and the protection is gone. The key must be derived from the intent, not the attempt. A workable scheme is the run ID plus the step number plus the tool name, created by your agent runtime and reused on every retry of that step. The model never sees it and cannot change it.

Be careful with the transport’s own request ID. In MCP, for example, the 2026-07-28 tools spec says the JSON-RPC id must differ when a request is retried, so it cannot serve as a deduplication key. Put the key in the tool arguments or in a header your server reads.

A server-side schema that holds up

You need a table with a unique key, and you need to claim the key before you do the side effect:

create table idempotency_keys (
  key          text primary key,
  request_hash text not null,
  status       text not null default 'in_progress',
  response     jsonb,
  created_at   timestamptz not null default now()
);

The handler claims the key first and branches on what it finds:

const claimed = await sql`
  insert into idempotency_keys (key, request_hash)
  values (${key}, ${hash(args)})
  on conflict (key) do nothing
  returning key`;

if (claimed.length === 0) {
  const row = await getKeyRow(key);
  if (row.request_hash !== hash(args)) return error(422, "key reused with different input");
  if (row.status === "in_progress") return error(409, "original request still running");
  return row.response; // replay the saved result
}

const ticket = await tickets.create({ ...args, externalRef: key });
await markDone(key, ticket);
return ticket;

Two details matter here. The insert is atomic, so two simultaneous retries cannot both win. And the downstream write carries the key as an externalRef, which handles the awkward case where the process dies after creating the ticket but before marking the key done. On the next retry you find a stale in_progress row, look up the ticket by externalRef, and finish the bookkeeping instead of creating a second one.

When the other system has no idempotency support

Plenty of APIs you call will not accept a key. Then you need a lookup you can trust. Write your own reference into a field the other system stores, such as a metadata field, a memo or a custom ID, and check for it before creating anything. If a retry finds a record with your reference, return it. If the other system offers no field for it, keep your own mapping table and reconcile against it on a schedule.

Where neither works, treat the action as unsafe to retry automatically and send it to a human or a review queue. That is a legitimate design choice for payments and outbound email.

Tell the model the truth about what happened

The error message the agent sees shapes what it does next. “Timeout” invites a blind retry. Something like “Outcome unknown: the request may have succeeded. Call get_ticket with reference X before creating another” gives the model a safe next move. The MCP spec describes tool execution errors, returned with isError: true, as feedback that clients should hand to the model so it can correct itself, which is the right channel for this.

This works best when you split tools by risk. A get_ticket or find_ticket tool is read-only and always safe to repeat, so give the agent a cheap way to check state before it writes.

Bound the retries as well

Idempotency makes retries safe, and it does not make them free. Cap the attempts per step, back off between them, and set a spend or call budget per run so a stuck loop stops itself. Log the key on every attempt, so a duplicate report becomes a query instead of a guess.

Test it by breaking it

Before you ship, write a test that kills the connection after the write commits but before the response returns, then retries. Run it with two simultaneous retries too. If you end up with one record both times, the pattern works. If you get two, the unique constraint is in the wrong place.

If an agent is writing to systems where a duplicate costs real money, this is worth an afternoon of review before launch. If you want a second pair of eyes on your tool layer, get in touch for a free first consultation.