AI Agent Idempotency: Safe Tool Calls After Retries
A timeout. The agent waits. No response comes. The retry mechanism kicks in.
What the code doesn't know: the first call did reach the email server. The email already went out. The customer received it. When the retry executes the same tool, the customer gets a second identical email. Sometimes, two charges.
No exception in the logs. Everything looks fine.
This is the hidden cost of building AI agents in production without idempotency in tool calls.
Why Retries Break Agents Without Idempotency
When an agent calls a tool and gets no response, it cannot know what happened:
- The call arrived, was processed, but the response was lost in transit
- The call never arrived because the error occurred before sending
- The call arrived but the server crashed before responding
Without idempotency, a retry duplicates any of these scenarios. Retries are necessary — and there's a detailed breakdown of exponential backoff retry and circuit breaker patterns worth reading. But if tools aren't idempotent, the very mechanism protecting your agent from interruptions can cause a bigger problem.
Read operations are naturally idempotent: you can run getCustomer() ten times and the result is always the same. The problem is writes with real-world side effects:
- Sending a transactional email
- Processing a payment through Stripe
- Creating a database record
- Sending a push notification
- Posting a message to Slack or WhatsApp
Any of these can execute twice from a single timeout-and-retry cycle.
The Idempotency Key Pattern
The solution is the same one used by Stripe, Twilio, and every serious payments API: idempotency keys.
Before executing a tool, generate a unique key that identifies that specific operation in that specific run. If the key already exists in cache, return the stored result instead of re-executing:
import { createHash } from 'crypto';
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
);
async function idempotentExecute<T>(
key: string,
operation: () => Promise<T>,
ttlSeconds = 3600
): Promise<T> {
const { data } = await supabase
.from('idempotency_cache')
.select('result')
.eq('key', key)
.gt('expires_at', new Date().toISOString())
.maybeSingle();
if (data?.result) {
return JSON.parse(data.result) as T;
}
const result = await operation();
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
await supabase.from('idempotency_cache').upsert({
key,
result: JSON.stringify(result),
expires_at: expiresAt,
});
return result;
}
Generating the Right Key
The key must uniquely identify this operation in this run. The correct formula combines the run ID with a hash of the input:
import { createHash } from 'crypto';
function getIdempotencyKey(
runId: string,
toolName: string,
input: object
): string {
const inputHash = createHash('sha256')
.update(JSON.stringify(input))
.digest('hex')
.slice(0, 16);
return `${runId}:${toolName}:${inputHash}`;
}
If the agent sends the same email in two different runs (because the business logic requires it), those are two separate operations with two different runId values — both execute correctly. But if the same run calls send_email twice with the same input due to a retry, the second call finds the key in cache and returns the original result without re-executing.
Practical Implementation: Email Sending
interface SendEmailInput {
to: string;
subject: string;
body: string;
}
const sendEmailTool = {
name: 'send_email',
description: 'Sends an email to the customer',
async execute(
input: SendEmailInput,
runId: string
): Promise<{ messageId: string }> {
const key = getIdempotencyKey(runId, 'send_email', input);
return idempotentExecute(key, async () => {
const messageId = await emailProvider.send(input);
return { messageId };
});
},
};
The first execution sends the email and stores the messageId. If the timeout occurs after the email was sent but before the result reaches the agent, the retry queries the table and returns the original messageId — without sending a second email.
The Database Schema
create table idempotency_cache (
key text primary key,
result jsonb not null,
created_at timestamptz default now(),
expires_at timestamptz not null
);
create index idx_idempotency_expires
on idempotency_cache(expires_at);
A cleanup job or Supabase function removes expired keys:
delete from idempotency_cache
where expires_at < now();
TTL by Tool Type
The TTL should exceed the maximum total run time plus retries:
| Operation type | Recommended TTL |
|---|---|
| Payments, transactional emails | 24 hours |
| Notifications, record updates | 1 hour |
| Document generation, exports | 7 days |
| Read queries | not applicable |
When Idempotency Isn't Needed
The pattern is only meaningful for operations with real-world side effects:
- Reads (
getCustomer(),searchProducts()): naturally idempotent, no cache needed - Logs and telemetry: a duplicate is harmless
- Analytical queries: no external state modified
Adding idempotency to reads consumes resources with no benefit.
Conclusion
An agent without idempotency in its write tools is an agent that will statistically duplicate a real-world action at some point. Not because the code fails — but because the world does. Timeouts, network drops, and server restarts are production events. The retry that prevents an interruption can cause a bigger problem if the tools aren't prepared for it.
If you already have retries configured in your agents, idempotent tool calls are the natural next step. Without it, every retry is a risk.
Building AI agents for production and need an architecture that survives timeouts and retries without duplicating actions? Message me on WhatsApp and we'll work through it together.