Development

Idempotency at the Agent Tool Interface

Antonio Evans8 min read

A close-up of a tool-call request envelope labeled with an idempotency key header.
Key points

Key points

  1. 1Make an Idempotency-Key header mandatory on every mutating tool call, generated once per durable step before dispatch, never by the model and never regenerated on retry.
  2. 2Persist the first response body and status in the same transaction as the effect, then return that stored body with a duplicate marker (Idempotency-Replayed: true) for any repeat of the same key.
  3. 3Test the contract by firing the same key twice and asserting exactly one side effect plus byte-identical bodies, not just two 200 responses.
  4. 4The contract breaks when the client retry deadline is shorter than the tool's processing window: the retry arrives before the first result is stored, so both attempts execute. Set the deadline above the window or reserve the key at entry.

The duplicate that survives a correct model

Agent retries are not a reasoning defect. They are a distributed systems behavior that arrived in the AI layer without the patterns that make it safe. A timeout after dispatch does not tell you whether the tool committed its side effect: the payment service may have accepted a charge even though the caller never saw a response (Formation). The model sees "no result," reasons that the call failed, and calls again.

This is why prompt engineering does not fix duplicate charges. The agent that produced the duplicate followed its instructions correctly; it genuinely did not know the first call succeeded. What was missing was external state that lets the tool answer "I already did this" (tianpan.co). The fix belongs at the tool boundary, in code, not in the system prompt.

The standard framing helps here. HTTP defines idempotency as the intended effect of many identical requests matching the effect of one, and it explicitly exempts POST and PATCH from that guarantee (restfulapi.net). Agent tools that create tickets, charge cards, or send mail are POST-shaped by nature, so they need the key contract to recover the property.

Declare the key as a required header

The Idempotency-Key request header exists precisely to make non-idempotent methods fault tolerant, and it is still a non-standard, experimental feature, so you own the semantics (MDN, IETF draft). That freedom is useful: you can be stricter than the public APIs.

The decision rule for your tool schema is simple.

Tool shapeKey required?Why
Read or list (search, fetch record)NoNaturally repeatable; a duplicate read costs tokens, not money
Create or mutate (charge, book, send, provision)Yes, hard fail without itEvery repeat is a possible duplicate side effect
Retry after a 5xx or timeoutYes, same key reusedThe original may have committed
Retry after a 429 rate limitNo new intent, but reuse the key anywayCheap insurance; the request likely never processed
Batch import rowYes, per-row keySafe resume after partial failure

Monday's API documentation makes the reuse rule explicit: never generate a new key on every retry attempt, because a fresh key identifies the retry as a different operation and defeats deduplication (monday.com). The same page recommends deriving keys deterministically for automated mutations, for example from the triggering event ID.

Where the key comes from matters more than its format. The orchestration layer should generate it from the durable execution step, such as {workflowRunId}:{stepId}, before dispatch, and persist its association with the logical operation and approved arguments (Formation). Do not ask the model to produce it. A newly sampled value can label the retry as a different operation. A fresh tool-call ID from the provider's response is not a stable business-operation identifier either, which is visible in how each provider mints those IDs, from Anthropic's toolu_ prefixes to Gemini's function-name-derived IDs (SylphAI).

A practical tool contract looks like this:

{
  "name": "create_ticket",
  "description": "Create a support ticket. Requires Idempotency-Key header.",
  "input_schema": {
    "type": "object",
    "required": ["title", "account_id"],
    "properties": {
      "title": {"type": "string"},
      "account_id": {"type": "string"}
    }
  }
}

The key stays outside the model-controlled arguments, in the header, so a regenerated plan cannot silently change it. Storing it in the arguments is acceptable only if your runtime guarantees it is attached after the model returns and never overwritten.

Return the stored result, not a fresh one

The server side has one correct shape: claim the key atomically, do the work, store the response in the same transaction, and replay it on every repeat. The atomic claim is what makes the whole thing race free. A SELECT then INSERT leaves a window where two concurrent retries both believe they are first, which is the classic time-of-check to time-of-use pattern OWASP documents for shared state (OWASP). A unique constraint on the key plus INSERT ... ON CONFLICT DO NOTHING closes that window in one statement (backendbytes.com).

A schema that supports the full contract:

CREATE TABLE idempotency_keys (
  key            VARCHAR(128) PRIMARY KEY,
  request_hash   VARCHAR(64)  NOT NULL,
  status         VARCHAR(16)  NOT NULL DEFAULT 'processing',
  response_code  INT,
  response_body  JSONB,
  created_at     TIMESTAMPTZ  NOT NULL DEFAULT now(),
  expires_at     TIMESTAMPTZ  NOT NULL
);

request_hash is the fingerprint. If the same key arrives with a different body, reject it rather than replaying a response for different arguments; MDN describes servers storing a hash of the request alongside the key exactly for this purpose (MDN).

The response shape should be stable and self-describing, so the agent runtime can tell a replay from a first execution and the test can assert on it.

{
  "ok": true,
  "idempotent_replay": true,
  "original_response": {
    "ticket_id": "T-4471",
    "created_at": "2026-02-11T09:14:03Z"
  }
}

Monday returns the cached body with an Idempotency-Replayed: true header and a 409 Conflict plus Retry-After when a duplicate arrives while the first is still processing (monday.com). Copy that split. A repeat after completion gets the stored body and the replay marker. A repeat during processing gets a conflict, so the caller polls instead of piling on work.

Two decisions deserve a rule of their own.

First, what to cache. The failure mode that bites teams is caching every response, including 500s, so a transient internal error becomes the permanent answer for that key (Mathildaduku). For agent tools, cache definitive outcomes (success, validation failure, business rejection) and leave an unresolved technical failure marked processing or failed_retryable so a later retry can re-attempt without creating a second side effect. Stripe's documented behavior is the stricter public reference point: it saves the resulting status code and body of the first request regardless of success, and subsequent requests with the same key return the same result (Stripe). Pick one and document it, because your test will encode the choice.

Second, expiry. Keys must outlive your longest realistic retry chain. Monday caches for 30 minutes, Stripe keeps keys for at least 24 hours, and general guidance lands between 24 hours and 7 days (monday.com, backendbytes.com, Zuplo). For agent workflows, size it to your slowest recovery path, including a human restarting a crashed orchestrator the next morning. Storage cost is small next to a duplicate refund.

If the tool calls an external provider, the guarantee must reach that provider's own idempotency contract. A local ledger proves intent; only the service that owns the effect can prevent a second charge, and where the mutation and its deduplication record share a transactional store they should commit atomically (Formation, backendbytes.com).

The same-key-twice test that asserts one side effect

A contract you cannot test is a wish. The test has three assertions and one of them is the one teams skip.

  1. Send the call with key K.
  2. Send the identical call with key K again.
  3. Assert the downstream effect count is exactly one (one row in charges, one sent message, one created resource).
  4. Assert both response bodies are identical after normalization, including the idempotent_replay flag and the original identifier.
  5. Assert the second call did not advance any business counter, such as an inventory decrement.

The third assertion is the important one. Two 200 responses with the same body prove the response layer is consistent; they do not prove the side effect happened once, because a tool that executes twice and returns a cached body passes a body-only check. Count effects at the sink.

A minimal test, framework agnostic:

def test_same_key_twice_one_effect(client, sink):
    key = "wf-8812:step-3:create_ticket"
    body = {"title": "SSO broken", "account_id": "A-1029"}

    first = client.post("/tools/create_ticket", json=body,
                        headers={"Idempotency-Key": key})
    second = client.post("/tools/create_ticket", json=body,
                         headers={"Idempotency-Key": key})

    assert first.status_code == 200
    assert second.status_code == 200
    assert sink.tickets_created == 1
    assert first.json()["original_response"]["ticket_id"] == \
           second.json()["original_response"]["ticket_id"]
    assert second.json()["idempotent_replay"] is True

Then add the variants that catch real bugs. Send the same key with a different body and assert a 4xx fingerprint rejection. Send two requests with the same key concurrently, from separate connections, and assert exactly one effect plus one conflict or replay; OWASP's parallel-request guidance describes firing requests through a release gate to widen the collision window, which is the right trick for making this deterministic (OWASP). Delete the first response row and resend to confirm behavior after expiry. Finally, run the test with the tool's real latency, not a stub, because the next section is where stubs lie.

The deadline mismatch that reopens duplicates

Here is the failure the same-key-twice test will not catch if your stub is instant.

The client's retry deadline (say 5 seconds) is shorter than the tool's server-side processing window (say 12 seconds for a provision step that waits on a downstream queue). The first attempt times out at the client. The retry arrives at second 5. Your tool has not finished, so the key row is still processing, and depending on how you wrote the code, one of two things happens: the retry gets a conflict (correct) or it re-enters the work (wrong). If the handler treats "no stored response yet" as "proceed," both attempts execute. The key existed the whole time and bought nothing.

The arithmetic is the rule: client retry deadline must exceed the tool processing window plus your storage write. If the p99 processing time is 12 seconds and the stored-response write takes 200 ms, a 5-second deadline guarantees at least one retry that lands mid-flight, and a 15-second deadline makes that retry impossible for almost every call. This is the same at-least-once reasoning that applies to any unreliable channel, where the ack can always be lost, so the receiver must deduplicate rather than the sender guess (backendbytes.com).

Two fixes, and you should use both.

Fix the deadline. Set the client retry deadline above the tool's p99 processing window and publish that number in the tool description so the agent runtime and any wrapper honor it. Backoff with jitter controls timing and load but does not establish whether a mutation already happened, so it is not a substitute for the deadline rule (Formation).

Reserve the key at entry. Insert the processing row before any side effect, in its own commit. Any later state, including a crash, is then visible to the next attempt as "work in flight or done," never as "new request." This is the only ordering that survives a process restart between claim and effect, and it matches the atomicity requirement that the mutation and its deduplication record commit together where they share a store.

There is a third case worth writing into the runbook: the tool succeeds, the response write fails. Now the key says processing forever while the side effect exists. A reconciliation job that compares in-flight keys older than the processing window against the downstream system is what closes that gap, and leaving unresolved outcomes for reconciliation is the honest answer rather than pretending a wrapper that remembers successful responses is sufficient (Formation).

Agent frameworks make this worse by retrying at the LLM layer, where the model observes a failure and decides to call again, while nothing in that loop tracks whether the tool already executed (tianpan.co). That is precisely why the deadline and the reservation have to live in the orchestrator and the tool, not in the loop.

Rollout checklist

ItemDone when
Header contractMutating tools reject calls with no Idempotency-Key, with a message the model can read
Key originOrchestrator derives keys from durable step IDs and persists them across restarts
Atomic claimUnique-constraint insert reserves the key before any side effect
FingerprintSame key with different body returns a 4xx, not a replayed response
Stored resultResponse code and body persisted in the same transaction as the effect
Replay markerRepeats carry idempotent_replay or Idempotency-Replayed: true
In-flight duplicateConcurrent repeat returns 409 with Retry-After
TTLExpiry exceeds the longest realistic retry and recovery path
DeadlineClient retry deadline above the tool p99 processing window, documented
Effect testSame-key-twice test counts one effect at the sink, runs concurrently, and runs at real latency

If you are building the orchestrator and would rather not assemble the retry, storage, and reconciliation pieces yourself, the team at Quanta Intelligence builds agent systems with these contracts baked in, and Quanta Inference gives every tool and model one OpenAI-compatible endpoint to call them through.

FAQ

FAQ

Does every tool call need an idempotency key?

No. Reads and lists are naturally repeatable and gain nothing from a key, which is why Monday ignores the header on queries (monday.com). Require it on anything that creates, charges, sends, or mutates shared state. Make it a hard rejection rather than an optional field, because an optional safety mechanism is one the agent will eventually omit on exactly the retry that matters.

Should the model generate the idempotency key?

No. The orchestration layer should generate it from the durable execution step before dispatch and reuse it across transport retries, model-requested retries, and recovery after a restart (Formation). A newly sampled value can identify the retry as a different operation and defeat deduplication entirely. Attach the key outside model-controlled arguments so a regenerated plan cannot change it.

What should a duplicate request return?

After the first attempt completes, return the stored status and body with a replay marker such as idempotent_replay or Idempotency-Replayed: true (monday.com). While the first attempt is still processing, return 409 with a Retry-After header so the caller waits rather than starting a second execution. Never return a fresh result computed a second time.

Should failed responses be cached?

Cache definitive outcomes, including business rejections and validation failures, because repeating them changes nothing. Treat transient technical failures differently: caching every 500 turns a momentary internal error into the permanent answer for that key, which is a well documented debugging trap (Mathildaduku). Document whichever policy you choose, since Stripe's public contract takes the stricter line and replays failures too (Stripe).

Why does my idempotency test pass while production still duplicates?

Almost always a deadline mismatch. The test stub returns instantly, so the retry never lands inside the processing window. In production the client's retry deadline is shorter than the tool's processing time, so the retry arrives before the first result is stored and the handler re-enters the work. Re-run the same-key-twice test with real latency and concurrent connections, and check that the key is reserved at entry before any side effect.

Sources

Sources

  1. Agent Tool Call Retries: Idempotency and Recovery
  2. The Idempotency Problem in Agentic Tool Calling
  3. Idempotency - What is an Idempotent REST API?
  4. Idempotency-Key header - HTTP - MDN Web Docs
  5. The Idempotency-Key HTTP Header Field
  6. Idempotency
  7. The Ultimate Guide to Agentic Tool Calling - SylphAI Blog - AdaL
  8. Race Conditions | OWASP Foundation
  9. Idempotency Patterns: Building Retry-Safe Distributed Systems
  10. How to Design Idempotent APIs Safely: What to Cache and ...
  11. Idempotent requests | Stripe API Reference
  12. Implementing Idempotency Keys in REST APIs
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles