- 01Every idempotency pattern in common use assumes a client that repeats the identical request. An agent does not.
- 02Derive the key from the intent — entity, operation, occurrence — not from a request hash and never from a per-attempt UUID.
- 03Timeouts are more dangerous than errors: an error says nothing happened, a timeout says nothing at all.
- 04Give every mutating tool a matching read. An agent that can check does not need to guess.
- 05Log the intent before the attempt. Reconciliation after a crash needs a record of what was tried, not only what succeeded.
What idempotency means here
The concept is old and well understood in web APIs. What follows is not an introduction to it — it is an account of the specific ways the standard approach fails when the caller is a language model in a loop rather than an HTTP client with a retry policy.
- The distinction that matters
- An operation is naturally idempotent when its own semantics make repetition harmless (a
PUT, an upsert, a set assignment). It is made idempotent when repetition is harmful and you add a deduplication record to suppress it. Almost everything an agent does that anyone worries about falls in the second category.
Why agents break the standard pattern
Consider what actually happens when a tool call fails. A conventional client catches the error and re-sends a byte-identical request. An agent does something more interesting and much less convenient: it reasons about the failure. It reads the error, forms a theory, and tries something slightly different — a corrected argument, a narrower scope, sometimes an entirely different tool that reaches the same outcome.
“A dumb client repeats itself. An agent reasons about the failure — which means the second attempt is not the same request.”
That behaviour is exactly why agents are useful, and it is precisely what defeats the two key strategies people reach for first.
| Key strategy | Works for an HTTP client | Fails for an agent because |
|---|---|---|
| Hash of the request body | Yes — the retry is byte-identical | The agent's second attempt has different arguments, so it hashes differently and is treated as new |
| Client-generated UUID per call | Yes — the client reuses its key on retry | The agent generates a fresh value each attempt, so nothing ever matches |
| Intent-derived key | Yes | This is the one that survives — see below |
The four retry shapes
The fourth is the one that actually causes incidents. A process dies mid-loop, something restarts it, and the replacement has the original objective but no knowledge of what the first instance already did. Any deduplication that lives in the agent’s own memory is gone with the process that held it.
Deriving the key from intent
refund:order_1234:line_2. That value is identical no matter how the agent phrases its arguments, which tool it routes through, or which process is running. It is derivable independently by any instance, which is what makes it survive a crash.The test for a good key: could a second, independent agent with the same objective compute the same key without having seen the first attempt? If yes, the key works. If it depends on anything the first attempt generated — a UUID, a timestamp, a request hash — it does not.
// ✗ Fails: different arguments produce a different hash
const key = sha256(JSON.stringify(toolInput));
// ✗ Fails: a fresh value every attempt, so nothing ever matches
const key = crypto.randomUUID();
// ✓ Works: derivable by any instance, from the intent alone
function refundKey(orderId: string, lineId: string) {
return `refund:${orderId}:${lineId}`;
}Note what the good key does not contain: the amount. That is deliberate and it is the interesting part. If the agent decides on a different refund amount the second time round, you almost certainly still want that suppressed — one refund per order line is the business rule, and a second refund for a different amount violates it just as badly as a duplicate for the same amount. Including the amount in the key would let it through.
Where the record lives
The conventional 24-hour key expiry exists to cover client-side network retries, which resolve in seconds. An agent operates on a different timescale: it may revisit the same order in a later session, next week, after a human asked it to take another look. If the rule is one refund per order line, then the deduplication record belongs on the order — as a real constraint in the domain model — not in a cache with a TTL chosen for a different problem.
A useful reframing: you are usually not implementing idempotency keys, you are implementing a uniqueness constraint on a business fact. If “a line can be refunded once” is true, the database should say so. Then the deduplication is a consequence of the schema rather than a layer that can be forgotten.
Timeouts, not errors, are the dangerous case
This asymmetry deserves more attention than it gets. Teams write careful error-handling paths and then discover that the errors were never the problem: the model handled a 422 invalid amount perfectly well. What it handled badly was thirty seconds of silence.
The fix is a read, not a retry policy
The single highest-leverage change is to give every mutating tool a corresponding read, and to say so in the tool description. An agent that can ask “does a refund exist on this line?” does not need to guess whether its own attempt landed.
const issueRefund = tool(
"issue_refund",
"Issue a refund for one order line. If a call times out, do NOT retry — " +
"call get_refunds first to check whether it already succeeded.",
{ orderId: z.string(), lineId: z.string(), amountCents: z.number().int() },
async (input) => { /* ... */ },
);
const getRefunds = tool(
"get_refunds",
"List refunds already issued for an order. Call this before issuing a refund, " +
"and after any issue_refund call that failed or timed out.",
{ orderId: z.string() },
async ({ orderId }) => { /* ... */ },
{ annotations: { readOnlyHint: true } },
);Read-your-write beats deduplication where it is available, because it removes the ambiguity instead of compensating for it. Keep the idempotency key anyway — it is the backstop for the case where the agent skips the check — but the read is what prevents the attempt.
Classifying your tools
| Class | Duplicate | Drop | Mechanism |
|---|---|---|---|
| Exactly-once | Harmful | Harmful | Intent-derived key + uniqueness constraint + reconciliation |
| At-least-once | Harmless | Harmful | Make naturally idempotent — upsert, set semantics, retry freely |
| At-most-once | Harmful | Tolerable | Human approval gate. Do not retry automatically at all |
Refunds, payments and order placement are exactly-once. Cache invalidation, internal state sync and log writes are at-least-once, and the right move there is to redesign the operation so repetition is meaningless rather than to guard it. Anything irreversible and externally visible — an apology email to a customer, a message to a partner, a public post — is at-most-once, and the honest answer is that no retry policy makes it safe. Put a person in front of it.
That third row is where the classification earns its keep. Teams repeatedly try to make “send the customer an email” safe with idempotency keys, when the actual requirement is that a human sees it once before it goes out, and never after.
The concurrent duplicate
The naive shape reads the key, sees nothing, and proceeds. Two callers both read nothing, both proceed, and you have the duplicate the key was supposed to prevent. Correct implementations insert first and let the database arbitrate.
// The unique constraint on (key) is what makes this safe.
// Insert first — the loser of the race gets a constraint violation, not a duplicate.
async function claim(key: string): Promise<"claimed" | "in_progress" | "done"> {
try {
await db.actions.insert({ key, state: "in_progress", startedAt: now() });
return "claimed";
} catch (e) {
if (!isUniqueViolation(e)) throw e;
const existing = await db.actions.findByKey(key);
return existing.state === "done" ? "done" : "in_progress";
}
}
async function issueRefundOnce(orderId: string, lineId: string, amountCents: number) {
const key = `refund:${orderId}:${lineId}`;
switch (await claim(key)) {
case "done":
// Replay the stored outcome rather than acting again.
return (await db.actions.findByKey(key)).result;
case "in_progress":
// Another attempt is mid-flight. Do NOT act. Report the conflict so the
// agent can wait and read, instead of assuming failure and retrying.
throw new InProgressError(key);
case "claimed":
break;
}
const result = await paymentProvider.refund({ orderId, lineId, amountCents });
await db.actions.complete(key, result);
return result;
}Two details in there are easy to get wrong. Replaying the stored result on a duplicate rather than returning an error is important, because an error is exactly the signal that provokes another attempt. And in_progress must be distinguishable from done: collapsing them either blocks a legitimate replay or tells the agent a mid-flight action has completed.
Orphaned claims
A claim written by a process that then died leaves a permanent in_progressrecord, and every later attempt is refused. Store the claim timestamp and treat claims older than the operation’s plausible duration as candidates for reconciliation — not for automatic release. Auto-releasing a stale claim is how you get the duplicate you built all of this to prevent, because the original operation may have succeeded after all.
Crash and reconciliation
This is a write-ahead log in miniature, and it is the same discipline as an audit trail: record the decision and its inputs at the moment the agent commits to it, then record the outcome separately. The gap between the two records is the set of actions whose status is genuinely unknown, and that set is what a human or a reconciliation job needs to inspect.
| Record state | What it means | Correct response |
|---|---|---|
| No record | Never attempted | Safe to act |
in_progress | Attempted; outcome unknown | Do not act. Read external state or escalate |
done | Completed; result stored | Replay the stored result |
in_progress | Stale — older than the operation’s plausible duration | Reconcile against the external system. Never auto-release |
The asymmetry in the last row is the point. An unknown-status action can only be resolved by asking the system that would have performed it. If the payment provider has a refund for that line, the action completed; if it does not, it did not. Guessing is not an option available to you, and it is not one you should make available to the agent either.
Testing it
The third has to run against the real database. A mocked store serialises access and will pass a check-then-write implementation that the actual one fails, which is a specific and common way to ship this bug with green tests.
There is also a test worth writing at the agent layer rather than the tool layer: give the agent a tool that times out on the first call and succeeds on the second, and assert on the number of real effects. That exercises the model’s retry behaviour, the tool description, and the deduplication together — which is the only combination that actually runs in production.
None of this is novel engineering. It is standard distributed-systems hygiene applied to a caller that is more capable and less predictable than the ones the patterns were designed for. The only real conceptual move is to stop keying on the request and start keying on the intent — because the intent is the thing that survives the agent changing its mind.
Frequently asked questions
What is idempotency?
An operation is idempotent when performing it more than once has the same effect as performing it once. Reading a record is naturally idempotent. Setting a value to 5 is idempotent. Adding 5 to a value is not, and neither is issuing a refund — which is why retrying the second kind safely requires extra machinery.
Why do AI agents need idempotency?
Because an agent retries on its own initiative, and it often cannot tell whether its previous attempt succeeded. A tool call that times out returns no result, so the agent assumes failure and tries again. Without a deduplication mechanism, that second attempt is a second real action — a second refund, a second order, a second email.
How is agent idempotency different from HTTP idempotency?
HTTP idempotency assumes a client that repeats the identical request with the identical key. An agent is not that client: it may retry with different arguments because it reasoned about the failure, retry through a different tool, or retry as a fresh instance after a crash. Keys derived from a request hash or generated randomly per attempt both fail under those conditions.
What should an idempotency key be derived from?
The intent, not the request. A key built from the business entity, the operation and the logical occurrence — for example refund:order_1234:line_2 — stays stable when the agent rewords its arguments, switches tools, or is replaced by a new instance. A hash of the request body does not, and a per-attempt UUID defeats the purpose entirely.
How long should an idempotency key be stored?
For the business window, not a technical TTL. The common 24-hour expiry exists to cover client-side network retries. An agent may revisit the same order days later, so if the rule is one refund per order line, the deduplication record belongs on the order itself rather than in a short-lived cache.
What should happen when an agent's tool call times out?
The agent should be able to read the current state rather than guess. A timeout is more dangerous than an error because an error confirms nothing happened while a timeout confirms nothing at all. Giving every mutating tool a corresponding read — so the agent can check whether the refund exists before issuing one — prevents more duplicates than any retry policy.
Sources
- 01Idempotent requests — Stripe, 2026
- 02Making retries safe with idempotent APIs — Amazon Builders' Library, 2024
- 03The Idempotency-Key HTTP Header Field — IETF HTTPAPI Working Group, 2025