OOOMETA/OS
← Back to Insights

November 2026 · 5 min read

One timeout, two refunds:
retry without a key

One timeout, two refunds: retry without a key

Key Definitions

Idempotency key A stable identifier for one business intent, reused on every retry so the server returns the first result instead of performing the action again.

At least once delivery The default delivery guarantee in distributed systems, where one call may arrive more than once, so safety for side effects has to come from idempotency rather than from the network.

Multi Round Trip Requests The MCP 2026-07-28 pattern where a server returns input_required and the client resends the original request with answers instead of the server calling back into the client.

At two in the morning a support agent decides to refund a 99 dollar order. It calls the refund tool. The request reaches the payment gateway, the gateway starts processing, and at second seven the connection drops. The client has a timeout, no result, and no error body. The framework does the standard thing and retries. The customer gets two refunds.

Every link in that chain behaved correctly. An HTTP client should retry on timeout. A slow gateway is normal. The model made no mistake. The gap sits earlier: nowhere in the protocol could the client ask whether the first call had landed.

This became common this year because the protocol changed underneath the tooling. The Model Context Protocol specification of 2026-07-28 removed protocol level sessions. With no session, whatever memory a call needs has to travel in the request, and a retried request looks exactly like the first one.

What the revision actually removed

The first two entries in the change log carry the story. Protocol level sessions and the Mcp-Session-Id header left the Streamable HTTP transport. The initialize handshake is gone, so each request carries its own protocol version and client capabilities in _meta. Servers that need state across calls now mint an explicit handle, return it in a result, and receive it back as an ordinary tool argument, with the state living in the tool storage.

The direction is right. The old model pinned a session to whichever process handled the handshake, which left three options: sticky sessions at the load balancer, a session store every instance reads and writes, or a single instance deployment nobody admits is single instance. All three are operational tax, and all three surface on the day traffic grows. The revision cancels that tax. What it does not do is give something else a home.

Read the change log and the subjects are identity, discovery, subscriptions, multi round trip interaction, and the task extension. Nothing covers retry semantics, idempotency keys, or request level deduplication. That is not an oversight to be corrected. The protocol delivers a call. It was never asked to decide which attempt the call is. The old session did not answer that question either, since a session only kept one client on one process and never promised a single side effect.

Resending the original request is now a normal path

If retries only happened on error paths, one wrapper could hide the problem. The revision put resending into the normal path. Multi Round Trip Requests replace the old server initiated sampling and elicitation calls: when a server needs more input it returns a result with resultType set to input_required, and the client resends the original request with inputResponses.

That case is safe on its own, because the server did not execute anything in the first round. What it establishes is a convention: resending the original request is legitimate client behavior, and no server may assume that one business intent arrives once. The same revision adds Mcp-Method and Mcp-Name headers so a proxy can tell a tools/call and the tool name without parsing JSON. Those headers solve routing and rate limiting. They do not solve deduplication, because they describe the kind of call, not which intent this is.

Why a retry at the model layer always duplicates

Most agent frameworks retry at the model layer. A tool call times out, the error returns to the context as a message, the model sees a failure and calls again. What that layer knows is one fact: no result arrived. What it cannot see is whether the tool already finished its side effect. To the tool, the second request carries the same arguments and no key, which makes it a new call.

Distributed systems have a name for this, at least once delivery, and the answer has been settled for twenty years. Either the write is idempotent by design, or the caller attaches a stable idempotency key. The difference with agents is who calls. In a service the caller is code, and code can hold one key for one business intent. With an agent the caller is a model, and the arguments are regenerated each time, which regenerates the key with them. Asking the model to produce its own idempotency key asks the least deterministic component to guarantee determinism, and that cannot hold structurally.

A half percent timeout rate, multiplied out

Ten thousand write calls a day, a half percent of them losing the connection or timing out, gives fifty calls a day with no receipt. Assume half of those had already executed, so twenty five duplicate side effects. At 120 dollars per refund that is three thousand dollars a day and ninety thousand a month. Those parameters are mine, set to show magnitude, and you can substitute your own gateway metrics.

The cost of deduplication sits on the other side. Ten thousand writes a day is three hundred thousand keys a month, each a key, a stored response and an expiry. One ordinary table holds that. So the gap is not technical and not financial. It is ownership: the timeout is a symptom for the infrastructure team, the duplicate refund is a symptom for finance, and no team has a metric on the space between them.

There is a quieter multiplier. Frameworks usually retry three or five times with backoff, so the duplicate rate does not stay at half of the timeout rate. It climbs toward the timeout rate itself. At three attempts the duplicate count in the example roughly doubles.

Finding the duplicate afterwards

After it happens, most teams cannot find it. The two actions look nearly identical in the system: one legitimate refund, one retried refund, seconds apart, same amount, same service account as the operator. Finding them means grouping by business key, order id plus action type, and treating anything that appears twice inside five minutes as a candidate for a human to confirm.

That exercise surfaces a harder fact. Idempotency wants a business key, and the business key is often missing from the tool arguments. A refund call carries the order id, so an argument hash works as a key. A ticket creation call has no business key until the ticket exists, so the arguments cannot supply one and only an upstream workflow id can.

That leaves a design choice: make every write tool accept an externally supplied business identifier, or have the execution layer apply one workflow id across everything. The first gives a precise key, the second gives wider coverage, and most teams end up doing both because either one alone leaves a class of tools outside.

Some will reach for a database unique constraint, and for a subset of tools that is enough, as long as a repeated execution collides with the same key. The cost appears after the collision: the second call receives a constraint error, the model reads a new error, and it very likely adjusts an argument and tries again, which turns one duplicate refund into a duplicate compensation record. The constraint stops the side effect. It does not stop the process.

Three places for the key, three ways to fail

The first placement is the client, generating a fresh UUID per call. It is the most common and the least useful. The retry carries a different value from the first attempt, the server sees two independent requests, and the deduplication logic never fires. The test is one line: is the key the same value on both sides of the retry.

The second placement is the tool handler, deriving the key from durable workflow state such as turn id, tool name and an argument hash. The workflow state does not change during a retry, so the key does not change, and the handler returns the first result. This is the only stable placement, and it only works if the key is produced outside the model. The model proposes the call. It does not get a vote on whether the call counts as new.

The third placement is the execution layer under the tool, a gateway or proxy. Coverage is the argument: configure once and every tool is protected. The cost is that the gateway has to read the request body to derive a stable key, which means parsing arguments per tool. The new headers help routing and do not help here.

One parameter gets missed: the key has to live as long as the retry window, not as long as the business window. Stripe defaults to twenty four hours, on the reasoning that retries happen within minutes and a day is generous. Set the expiry to five minutes and the nightly replay bypasses it, and nightly replay is exactly when batch agents do most of their work.

Split writes into three classes

The sturdier fix writes the retry policy into the tool contract instead of leaving it in a place everyone assumes exists. Sort tools by side effect, and let the policy follow the class.

Read only tools retry freely and need no key. They are most of every tool library, and the only class most teams test with real care.

Reversible writes need a key plus a compensation. The key prevents the duplicate. The compensation covers the case where the key store itself is lost, because a lost key with no compensation becomes an error nobody can trace back.

Irreversible writes need a confirmation gate and a hard stop. Transfers, deletions and outbound messages belong here. A hard stop means that once the retry budget is spent the call fails and hands context to a person, rather than letting the model find another way. Most frameworks do the opposite at this point: they let the model adjust an argument and try again, which is how one error becomes two different errors.

Tool libraries today carry no metadata for any of the three classes. The description is written for the model and covers when to use the tool and what the parameters mean. It never says what happens when the call runs twice. Nobody notices during a demo, because demo calls are single, watched, and never retried three times.

Our judgment

Retry safety is a property of the tool contract, not of model capability. The revision was right to remove sessions, and after that removal cross call memory has exactly two homes: the tool storage, or the caller persistent state. Both must be written by code, because only code can hold one identifier across attempts for one business intent. Every model generation is a fresh sample.

Two predictions that can be checked. First, tool manifests will gain a required field that declares the retry policy and where the key comes from, and a tool without it will fail integration review. Second, platform vendors will turn that field into a product capability configured rather than coded, because every customer hits the same problem and few get it right unaided.

Five questions for anyone wiring this up now. Which calls in this library have side effects, who maintains that list, and how often is it checked? Where does the key come from, and is it the same value across a retry? How long is it kept, and what is the degraded behavior when the store is unavailable? How many confirmations stand in front of an irreversible write, and who signs them? When a duplicate does happen, who reads the reconciliation record? If the third answer is missing, keep the tool out of production. Its failure mode will not appear on any dashboard.

Sources

Removal of protocol level sessions and Mcp-Session-Id, removal of the initialize handshake, server minted handles for cross call state, multi round trip requests returning input_required with the client resending the original request, Mcp-Method and Mcp-Name headers: MCP specification 2026-07-28 change log, primary. modelcontextprotocol.io/specification/2026-07-28/changelog

Engineering analysis of where an idempotency key can live after the stateless revision, including the duplicate refund failure chain: Swytchcode engineering blog, secondary, itself reading the change set through a third party summary, so verify wire semantics against the specification before shipping. swytchcode.com/content/mcp-stateless-idempotency

Twenty four hour default key expiry: Stripe API reference, primary. docs.stripe.com/api/idempotent_requests

At least once delivery and retry backoff with jitter: Amazon Builders Library, primary engineering reference. aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter

Parameters used in the example: ten thousand daily write calls, half a percent timeout rate, half already executed, 120 dollars per refund, three retry attempts. All five are mine, set to show magnitude, not vendor measurements or industry averages. The arithmetic can be recomputed from them.

FAQ

Where does cross call state live after the stateless revision+

The server mints an explicit handle, returns it in a result, and receives it back as an ordinary tool argument, with state stored by the tool itself. The protocol no longer carries a session, so any instance can serve any call.

Should the model generate the idempotency key+

No. Arguments are regenerated on every attempt and the key changes with them, which turns a retry into a new request. The key has to be derived from durable workflow state outside the model.

How should retry policy follow the tool class+

Read only tools retry freely with no key. Reversible writes need a key plus a compensating action. Irreversible writes need a confirmation gate and a hard stop once the retry budget is spent.

How long should a key be kept+

Long enough to cover the retry window. Stripe defaults to twenty four hours. A five minute expiry lets the nightly replay bypass deduplication, which is when batch agents do most of their work.

Can rate limiting replace idempotency+

No. Rate limiting reduces the number of calls and does not change the fact that one call already executed while its receipt was lost.

Run the same check on your own execution log

Send a redacted 30-day agent execution log and get an eight-row table in 48 hours: one row per technical item, each cell carrying its numerator, denominator and criterion, gaps sorted by whether a schema change is needed. First three engagements are $1,200.

See the field check →