AI Agent Idempotency: Stop Duplicate Tool Calls
Technical claims checked against Stripe’s idempotency documentation, Anthropic’s tool-use documentation, and a live Anthropic SDK issue on October 7, 2026. SDK behavior changes; verify the version you deploy.
An AI agent can call the same tool twice even when your model, logs, and HTTP client all appear healthy. A timeout may arrive after the payment was accepted. A tool result may fail to reach the agent, so the next loop emits the same call. A crashed session may replay a completed tool invocation instead of replaying its stored result.

This guide explains why agents amplify ordinary retry hazards, how to classify tools by side-effect risk, and how to implement a durable replay guard. It also includes a measured local fault-injection test. The DRISK framework is an editorial framework from PromptSphereHub, not an industry standard, and the local test is not a production benchmark. The experiment parameters, arithmetic, and reproducibility script are included in the article so the result can be inspected without trusting a headline number.
Why a successful tool call can be repeated
Distributed systems have an awkward failure state: the client cannot tell whether the server applied the operation. The connection can fail before the request arrives, while the server is processing it, or just after the server commits the result but before the response reaches the client. Stripe describes this ambiguity as a reason to design APIs that can safely retry requests with the same idempotency key.
Agents add more replay paths on top of that network uncertainty. The model can emit a duplicate call when it never receives a tool result. The orchestration layer can retry a failed result submission. A resume function can rebuild the transcript and execute old tool calls again. These paths are not proof that a particular agent framework always duplicates calls; they are failure modes your runtime must either prevent or make harmless.
| Replay path | What happens | Typical trigger |
|---|---|---|
| Explicit retry | Application code calls the same operation again. | Timeout, 5xx, or a transient network error. |
| HTTP or SDK retry | A POST may have reached the server even though the response was lost. | Connection reset after the server committed. |
| Agent-loop repetition | The model emits the same tool call because its result was not added to the conversation. | Tool-result delivery failure or orchestration bug. |
| Resume replay | Recovery re-executes a historical tool call instead of restoring its recorded result. | Crash recovery or a “retry run” button. |
A current example is Anthropic SDK TypeScript issue #1243. The issue reports a SessionToolRunner repeating a completed tool side effect when posting its result fails and reconciliation sees the unanswered tool-use event again. It is an open issue report, not a universal statement about Anthropic tool use. It demonstrates why a tool handler should be safe to re-enter even when the surrounding session is not.

Idempotency is not the same as exactly-once execution
“Exactly once” is a useful design goal but a dangerous promise. An idempotency key lets a server recognize retries of the same logical request and return the same result instead of applying the side effect again. It does not make a payment provider, your database, an email provider, and a warehouse participate in one atomic transaction.
Use more precise language:
- Idempotent operation: repeating the same logical request converges on the same effect.
- At-most-once effect per key: the guarded boundary does not intentionally perform the same keyed action twice.
- Exactly-once business outcome: a broader claim that requires careful coordination and is usually not guaranteed across independent systems.
For an agent article, the practical target is effectively once per business operation at each protected boundary. If a refund and an email must both happen, guard them separately and define what happens when the refund succeeds but the email fails. Idempotency reduces duplicate effects; it does not remove the need for compensation, reconciliation, or human review.
Classify tools before you add retries
Do not give every tool the same retry policy. Start with a simple risk taxonomy:
| Class | Examples | Default treatment |
|---|---|---|
| Class C — read-only | Search, GET requests, calculations. | Repeats do not duplicate a business side effect, but they can waste tokens and latency. |
| Class B — naturally idempotent | Set a status to a value, or upsert by a stable user ID. | Verify the natural identity and semantics. Repeating the same request should converge safely. |
| Class A — effectful | Payments, refunds, emails, SMS, file writes, deployment triggers, and inserts without a natural key. | Require a durable identity and an idempotency layer at the tool boundary. |

A database upsert can be Class B only if its unique key represents the intended business identity. “Insert a row with the same arguments” is not automatically safe. Two legitimate refunds may have the same amount, and two legitimate emails may have the same recipient. The identity must come from the business action, not from a convenient hash of whatever arguments the model happened to produce.
The DRISK framework for Class A tools
Use these five questions in a pre-ship review:
- Define the identity. What makes two invocations the same business action? A refund may use a ticket or refund-request ID. An email may use a campaign event ID. If you cannot define identity, you cannot safely deduplicate.
- Register intent. Claim the key in durable storage before execution. Make the claim atomic so two concurrent attempts cannot both become the owner.
- Idempotency key at the boundary. Pass the same key to the downstream API when it supports idempotency. Stripe documents this pattern for mutating requests and stores the first status and body for later replays.
- Short-circuit replays. If a completed result exists, return it without touching the external system. If an attempt is in progress, wait, return an explicit in-progress state, or use a recovery worker. Do not silently execute a second copy.
- Keep the audit trail. Store the key, run ID, tool name, argument fingerprint, state transitions, provider response, and replay count. Logging is not the prevention mechanism, but it is how you investigate and reverse the cases that remain.
Stripe’s official documentation says all POST requests accept idempotency keys, while GET and DELETE are idempotent by definition and do not need them. It also says Stripe stores the resulting status code and body for the first request, including failures, compares parameters on reuse, and may prune keys after at least 24 hours. These are Stripe’s semantics; do not copy the retention window or failure behavior without deciding what your own system requires.
A safer wrapper pattern
The key design change is that the caller supplies a business-operation key. Do not derive a dedupe key only by hashing tool arguments: the same arguments can represent two legitimate actions, while semantically identical arguments can serialize differently. Canonicalize arguments for auditing, but make the operation identity explicit.
type Claim =
| { kind: "new"; key: string }
| { kind: "replay"; key: string; result: unknown }
| { kind: "in_progress"; key: string }
| { kind: "mismatch"; key: string };
// A failed claim may be retried only after the store decides that the
// previous attempt was safe to retry. An ambiguous side effect must go
// to reconciliation, not automatic re-execution.
declare function claimOnce(input: {
key: string;
toolName: string;
argsFingerprint: string;
runId: string;
}): Promise<Claim>;
declare function saveSuccess(key: string, result: unknown): Promise<void>;
declare function saveFailure(key: string, error: unknown): Promise<void>;
type Execute = (args: object, idempotencyKey: string) => Promise<unknown>;
async function callEffectfulTool(
toolName: string,
operationKey: string, // e.g. refund_request_id, not a random retry ID
args: object,
runId: string,
argsFingerprint: string,
execute: Execute,
): Promise<unknown> {
const claim = await claimOnce({
key: operationKey,
toolName,
argsFingerprint,
runId,
});
if (claim.kind === "replay") return claim.result;
if (claim.kind === "in_progress") {
throw new Error(`tool_call_in_progress:${claim.key}`);
}
if (claim.kind === "mismatch") {
throw new Error(`idempotency_key_reused_with_new_arguments:${claim.key}`);
}
try {
const result = await execute(args, operationKey);
await saveSuccess(operationKey, result);
return result;
} catch (error) {
// The store must distinguish a confirmed-before-execution failure
// from an ambiguous post-send failure. Do not automatically reopen
// an ambiguous key: reconcile it or return an explicit review state.
await saveFailure(operationKey, error);
throw error;
}
}
This is an interface pattern, not a drop-in database implementation. claimOnce needs a unique constraint or equivalent atomic compare-and-set. It should persist the key, tool name, argument fingerprint, owner, and state. A completed row should return the stored result. A conflicting fingerprint should fail closed instead of treating a different business action as a replay. A failed claim may transition back to retryable only when the system knows the side effect was not sent; post-send ambiguity must go to reconciliation rather than automatic re-execution.
The hardest case is a failure after the external side effect but before saveSuccess. The downstream service should receive the same key so a retry can recover the result. If the service has no idempotency support, your wrapper can prevent many duplicates, but it cannot prove what happened after an ambiguous network failure. Add reconciliation or a human-review path for high-value actions.
What the agent should see after a replay
When a duplicate tool call arrives, return the original result in the same shape the model would have received from a fresh execution. Do not return a vague “duplicate” message that leaves the model unsure whether the customer was charged or the email was sent. The transcript should say, in effect, “the original operation completed; here is its recorded result.”
Also persist tool results before marking the agent run complete, and make resume logic load recorded results from storage. Prompt instructions such as “never repeat a tool call” can reduce some model-level repetition, but they are not an enforcement boundary. Put the safety property in the wrapper, database, provider API, permission system, or workflow engine.
Anthropic’s tool-use documentation describes the client-tool round trip: the model returns a tool_use block, your application executes it, and your application sends back a tool_result. That protocol tells you where the replay guard belongs: in the application code that executes the tool, not only in the model prompt.
Measured local test: the kill-and-replay experiment
I ran 1,000 identical refund operations with a fixed seed. Each first attempt had a 30% probability of committing the fake refund while losing its response; the fixed seed realized 296 such faults. The baseline retried exactly once without an idempotency key. The guarded path also retried once, but with the same downstream key, so the provider returned the stored result instead of applying the refund again.
| Mode | Runs with duplicate refunds | Total refund side effects | Completed runs | Stored results replayed |
|---|---|---|---|---|
| Without idempotency | 296 / 1,000 (29.6%) | 1,296 | 1,000 (100%) | 0 |
| With downstream idempotency key | 0 / 1,000 (0%) | 1,000 | 1,000 (100%) | 296 |

The arithmetic is explicit: 1,000 original attempts + 296 one-time retries = 1,296 baseline side effects. The result is deliberately narrow. It shows that a downstream idempotency key can make this particular ambiguous-response failure safe in a fake provider. It does not show that every agent runtime, email service, payment provider, or database behaves this way. The experiment should be repeated with your own runtime, concurrency model, and failure points before you make a production claim.
To reproduce it, use a toy agent with search_orders as Class C, upsert_note as Class B, and issue_refund as Class A. Inject a failure after the fake refund commits but before the response returns. Compare the wrapper disabled and enabled. Keep the exact model, runtime, retry policy, fault-injection point, seed, and fixture version in the result record. Never run the test against real payments or customer email.
Edge cases that break naive implementations
- Legitimate repeats: two refunds for different tickets or two scheduled emails may share similar arguments. Use a business operation ID, not a blanket “same tool” rule.
- Key expiry: Stripe documents pruning after at least 24 hours. Your retention window must match the period in which a replay is plausible, and expiry must be observable.
- Parameter mismatch: the same key with different arguments should be rejected. Otherwise a retry can silently become a different action.
- Concurrent claims: two requests can arrive at the same time. Use a unique constraint, transaction, lock, or compare-and-set operation.
- Partial failure: idempotency does not make “charge card, update inventory, send email” one transaction. Use an outbox, reconciliation job, compensation, or human review.
- Unreliable side-effect providers: if an email provider has no idempotency key, keep an outbox record and make the sender’s dedupe behavior explicit.
- Sensitive keys: avoid putting emails, payment details, or personal identifiers directly into keys. Stripe recommends high-entropy keys and warns against sensitive data in them.
Pre-ship checklist
- Every tool is labeled read-only, naturally idempotent, or effectful.
- Every effectful action has a defined business identity.
- The idempotency key represents the operation, not the retry attempt.
- A durable store atomically claims the key and records the argument fingerprint.
- Completed replays return the stored result without touching the external system.
- In-flight requests have an explicit wait, lock, or in-progress response.
- Downstream APIs receive the same key when they support idempotency.
- Ambiguous failures go to reconciliation or review instead of being assumed safe.
- Resume logic restores tool results instead of re-executing historical calls.
- Key retention, parameter mismatch, and partial failure behavior are documented.
- A staging kill-and-replay test runs before launch.
- Audit logs include key, run ID, tool, state, result, and replay count.
For the audit and monitoring layer around these events, connect this design to our AI agent tool-call auditing guide and the TRACE observability playbook. If your agent also calls APIs with structured arguments, the structured-output accuracy guide covers why valid JSON is not enough.
Conclusion
Retrying an LLM request usually costs tokens. Retrying a tool call can charge a customer, send an email twice, create a duplicate record, or trigger a deployment twice. The fix is not a stronger instruction to the model. It is an explicit operation identity, a durable claim, a guarded tool boundary, a stored result, and a recovery path for ambiguous failures.
Start by listing the tools your agent can call. Mark the effectful ones. Give each business action a stable key, test what happens when the response disappears, and make the replay return the original result. That turns duplicate tool calls from a production surprise into a behavior you can reason about and test.
Sources and further reading
- Stripe API Reference: Idempotent requests — supports key reuse, stored status and body, parameter comparison, key pruning after at least 24 hours, and POST/GET/DELETE behavior.
- Stripe Engineering: Designing robust and predictable APIs with idempotency — explains ambiguous network failures, idempotency keys, cached results, backoff, and jitter.
- Anthropic Claude Platform Docs: Tool use overview — documents the
tool_useandtool_resultapplication round trip. - Anthropic SDK TypeScript issue #1243: SessionToolRunner repeats completed tool side effects after result delivery fails — a live issue report describing one concrete replay failure mode; it is not a universal claim about the SDK.
- Brandur Leach: Implementing Stripe-like idempotency keys in Postgres — detailed reference for durable state, parameter fingerprints, locking, recovery points, and concurrent requests.
- RFC 9110: HTTP idempotent methods — background on HTTP method idempotency and why application-level POST keys are still useful for effectful operations.
Join the conversation