Here's a bug I keep seeing in agent code: a tool call times out, the framework retries, and the customer gets two refunds. Or two emails. Or two tickets.
The model didn't do anything wrong. The tool did exactly what it was told, twice. The fix isn't a better prompt. It's a decades-old backend pattern: idempotency keys.
Why agents duplicate side effects
An agent loop gives you more ways to run the same action twice than a normal API client does:
- Network retries. Your HTTP client or agent framework retries a call that timed out, but the first attempt actually succeeded on the server.
-
The model asks again. OpenAI's function-calling guide says a response "can include zero, one, or multiple calls," so you should assume there are several. The same guide even notes that one
gpt-4.1-nanosnapshot "can sometimes include multiple tool calls for the same tool" when parallel tool calls are enabled, and recommends disabling that feature for it. - Crash and resume. Your worker dies after the side effect but before saving the tool result. On restart, the agent replays the step.
- The model didn't see the result. If the tool output never made it back into context (truncated, errored, dropped), the model reasonably tries again.
Reads are fine to repeat. Writes are not.
Split your tools by side effect
Before you add any retry logic, label every tool:
| Class | Example | Safe to retry blindly? |
|---|---|---|
| Read |
get_order, search_docs
|
Yes |
| Idempotent write | set_ticket_status(id, "closed") |
Yes, same end state |
| Non-idempotent write |
issue_refund, send_email, create_ticket
|
No |
The third row is where you need idempotency keys.
How idempotency keys work (Stripe's version)
Stripe's API docs describe the pattern clearly:
- The client generates a unique key per logical operation and sends it with the request (
Idempotency-Keyheader). Stripe suggests V4 UUIDs or another random string with enough entropy; keys can be up to 255 characters. - For API v1, Stripe saves the status code and body of the first request with that key once execution begins. Retries with the same key get the saved response, including
500errors. - The idempotency layer compares the incoming parameters to the original request and errors if they differ, so a reused key can't silently do something different.
- Keys can be pruned once they're at least 24 hours old; after that, a reused key is treated as a new request.
- Don't put sensitive data (emails, personal identifiers) in the key.
The important part for agents: the key must be stable across retries of the same intent. A fresh UUID per HTTP attempt defeats the whole point.
Where the key comes from in an agent
Derive it from something that stays the same when the same action is retried:
- the agent run ID + step number, or
- the model's tool
call_id(OpenAI returns one per function call), or - a business key like
refund:{order_id}, when "at most one refund per order" is the real rule.
Here's a minimal wrapper for your own tool layer:
import hashlib, json
def idem_key(run_id: str, tool: str, args: dict) -> str:
payload = json.dumps(args, sort_keys=True)
return hashlib.sha256(f"{run_id}:{tool}:{payload}".encode()).hexdigest()
def run_side_effect(store, run_id, tool, args, fn):
key = idem_key(run_id, tool, args)
saved = store.get(key) # e.g. a Postgres table with a UNIQUE key
if saved is not None:
return saved # replay: return the first result, don't redo it
result = fn(**args, idempotency_key=key) # pass through if the downstream API supports it
store.put(key, result)
return result
Two details matter:
-
Use a unique constraint, not a "check then insert". Two parallel calls can both see "not found" otherwise. Insert a
pendingrow first and let the database reject the duplicate. - Pass the key downstream when the provider supports it (Stripe does). Your store protects you from your own retries; the provider's key protects you when your store write fails after the provider succeeded.
Tell the model what happened
Return a clear tool result on replay, for example:
{"status": "already_done", "refund_id": "re_123", "note": "This refund was already issued for this order. Do not retry."}
A model that sees "already done" stops. A model that sees a vague error tries again.
Checklist
- Label every tool: read, idempotent write, non-idempotent write.
- Every non-idempotent write takes an idempotency key derived from run + step,
call_id, or a business key. - Store results under a unique constraint and return the saved result on replay.
- Pass the key to downstream APIs that support it.
- Return explicit "already done" results to the model.
- If a model snapshot duplicates parallel calls, set
parallel_tool_callstofalsefor it. - Test it: kill the worker right after the side effect and replay the run. You should see one refund, not two.
Your agent will retry. Make sure the world only changes once.
References
- Stripe API: Idempotent requests
-
OpenAI: Function calling guide (multiple calls per response,
call_id,parallel_tool_calls) - IETF draft: The Idempotency-Key HTTP Header Field
Top comments (0)