AI agent idempotency: how to stop duplicate charges, emails and records
Why AI agents repeat side effects (model re-calls, framework retries, restarts, timeouts after the write), how to choose an idempotency key per operation, and how to handle a call whose outcome is unknown.
Short answer: give every side-effecting tool call a stable operation identity before the first attempt. For Anlyon template actions, repeating the same request and idempotency key retrieves the current invocation without sending another external request. Treat a lost response as unknown, reconcile it, and make any new execution an explicit decision. Provider idempotency is a separate layer with its own retention contract.
Why agents repeat side effects
Agents duplicate writes more often than ordinary API clients, for reasons that stack:
| Cause | What happens |
|---|---|
| The model calls the tool again | It did not see the result, lost it in a long context, or decided to "make sure". Same arguments, new call. |
| The framework retries | Many agent loops retry a tool that threw. A write that failed after being applied is applied again. |
| The worker restarts | A crash between "call the API" and "record that we called it" re-runs the step on recovery. |
| The response is lost | The request reached the destination and succeeded. The response did not come back. From the agent's side, that looks like a failure. |
The last one is the dangerous one, because every sensible retry policy says to try again.
Choose the key from the operation
An idempotency key identifies what you are doing, not when:
refund:ord_42: one refund per order. For an intentional later partial refund, persist a second identity such asrefund:ord_42:partial_2with the business request.email:welcome:user_7: one welcome email per user.- A UUID created once and persisted with the business request also works.
- A new UUID or timestamp on every attempt makes retries look like new operations.
Generate the key before the first attempt, store it with the step if your agent has durable state, and pass the identical key on every retry of that operation.
Idempotency at Anlyon's layer
actions.invoke() accepts an idempotency key and sends it as an Idempotency-Key header:
const { data } = await anlyon.actions.invoke(
'refund-order',
{ charge: 'ch_3P9x', amount: 12000 },
{ idempotencyKey: `refund:${orderId}` },
);
The key is claimed atomically before the handler runs, so concurrent duplicates perform the side effect at most once:
| Situation | What happens |
|---|---|
| Same key, same request | The invocation's current state returns with X-Idempotent-Replay: current-state. Nothing is dispatched again. |
| Same key, different request | 409 idempotency_key_reuse. |
| Duplicate arrives while the first is in flight | It waits, then replays, or gets 409 idempotency_request_in_progress. |
| The invocation failed or is unknown | Its key stays attached. Repetition returns that state. It does not perform a new execution. |
| More than 24 hours later | The invocation retains its key while the invocation exists, even after the short-term response cache expires. |
| Admission was refused before an invocation existed | No invocation key was recorded. Correct the cause before resubmitting. |
The generic 24-hour cached-response policy for other API mutations is not the action-invocation contract. See the full idempotency rules.
Idempotency at the destination
Your own layer's deduplication protects against duplicates reaching it. It cannot help once a request has gone out and its outcome is lost. For that, the destination has to recognise the retry. The Anlyon stripe.refund adapter, for example, sends an idempotency key on every write, and a repeat inside 24 hours returns Stripe's first result.
Forward the same key to the destination by making it part of the action's input and putting it in a header. Use a bodyTemplate so the key is not also sent in the request body:
await ops.actions.create({
name: 'refund-order',
method: 'POST',
urlTemplate: 'https://api.stripe.com/v1/refunds',
headers: {
Authorization: 'Bearer {{secret:STRIPE_KEY}}',
'Content-Type': 'application/x-www-form-urlencoded',
'Idempotency-Key': '{{input.idempotencyKey}}',
},
bodyTemplate: { charge: '{{input.charge}}', amount: '{{input.amount}}' },
inputSchema: {
type: 'object',
properties: {
charge: { type: 'string', pattern: '^ch_' },
amount: { type: 'integer' },
idempotencyKey: { type: 'string', pattern: '^[A-Za-z0-9:_-]{8,255}$' },
},
required: ['charge', 'amount', 'idempotencyKey'],
},
});
const key = `refund:${orderId}`;
await anlyon.actions.invoke(
'refund-order',
{ charge: 'ch_3P9x', amount: 12000, idempotencyKey: key },
{ idempotencyKey: key },
);
Anlyon retains the identity on the invocation. The provider key is a separate defense for the outbound request. Do not assume its retention lasts as long as Anlyon's record, or bypass Anlyon's recovery flow by calling the provider directly.
When the outcome is unknown
Anlyon does not retry an action invocation. When a request times out, or the connection dies after the request was written, or the destination answers with a 5xx, the honest answer is that the destination may have processed it. Anlyon records that as unknown, not failed, and stops.
const { data } = await anlyon.actions.invoke('refund-order', input, { idempotencyKey: key });
switch (data!.status) {
case 'succeeded':
break;
case 'failed':
// Inspect the failure. A deliberate new execution uses a new key and retryOf.
break;
case 'unknown':
// It may have happened. Reconcile with the destination.
// Reusing this key reads the existing invocation; it does not resend the refund.
break;
case 'pending_approval':
// Parked for a human. Poll the invocation after the decision.
break;
}
Telling the model about unknown matters too. If your tool returns "error" for an unknown outcome, the model will reasonably try again. Return something like "the refund may already have been issued. Do not retry, it is being checked" instead.
Retries that are safe
For a template action, an authorized operator first resolves an unknown outcome with evidence. A deliberate new execution after a confirmed failure uses a new idempotency key and retryOf naming the failed invocation. Resolving an invocation does not free its original key. See resolution and explicit retry.
Adapter-backed actions instead use governed-effect recovery, including operationKey and effect-level reconciliation. Do not resolve them through the template invocation endpoint. Different operation keys can still request multiple refunds. Use shared impact limits to bound supported effects.
Checklist
- Give every side-effecting tool an idempotency key derived from the operation.
- Create the key before the first attempt and reuse it on every retry.
- Forward the key to destinations that support it, such as Stripe.
- Treat
unknownas "may have happened": reconcile before considering a new execution. - Tell the model when not to retry, in the tool's result.
Related
- What happens when an AI agent's API call times out: the unknown outcome in depth.
- We shipped an idempotency bug that ran your side effects twice: a race in our own implementation, and the fix.
- Docs: Idempotency and retries · Outcomes and receipts
Free during Early Beta Access, no credit card. Start building →
Frequently asked questions
What is idempotency for AI agent tool calls?
It means repeating the same logical operation produces one effect, not several. If the agent, its framework or its worker sends the same refund twice, the customer is refunded once. It is implemented with an idempotency key that identifies the operation, sent with every attempt of it.
How should I generate an idempotency key for an agent?
Derive it from the operation, not the attempt: refund:ord_42 rather than a fresh UUID or a timestamp per call. Create it once, before the first attempt, and reuse it for every retry of that operation. A new key per attempt makes every retry look like a new request.
Should an agent automatically retry a failed tool call?
Do not automatically repeat an uncertain write. For Anlyon template actions, the same key retrieves the existing invocation. Reconcile an unknown outcome first. A deliberate new execution after a confirmed failure uses a new key and retryOf. Governed effects have a separate recovery contract.
Does Anlyon retry action invocations?
No. Anlyon does not retry an action invocation. If a request may have reached the destination and the outcome cannot be confirmed, the invocation is recorded as unknown and you reconcile before retrying.
