PromptisePromptise
Docs
GitHubGet started
Promptise - AI Framework LogoPromptise

The foundation layer for agentic intelligence. Build, secure and operate autonomous AI systems with Promptise Foundry.

pip install promptise

[01] Foundry

  • MCPcast
  • The Promptise Agent
  • Reasoning Engine
  • MCP
  • Agent Runtime
  • Prompt Engineering
  • Execution Engine
  • Agent Identity

[02] Resources

  • Documentation
  • GitHub
  • Guides
  • Learning Paths
  • Questions

[03] Company

  • About
  • Terms of Service
  • Privacy Policy
  • Cookie Policy
  • Subprocessors

© 2026 Promptise by Manser Ventures. All rights reserved.

Open source · Python

← Guides> Tools and the Model Context Protocol (MCP)

Idempotent Tool Calls: Make AI Agent Retries Safe

Idempotent tool calls make AI agent retries safe: where retries come from, why timeouts duplicate actions, and how to add idempotency keys, with real code.

Level
Intermediate
Reading time
18 min
Published
Oct 22, 2026
Last updated
Oct 22, 2026
Tested with
Promptise 1.6.0
By
Promptise Team
  • Agent Runtime
  • Tool Calling
  • Reliability and Fallbacks
Contents · 16 sections↓
  1. 01How do you make AI agent tool calls safe to retry?
  2. 02Why do AI agents run the same action twice?
  3. 03What happens when a tool call times out?
  4. 04When do you need idempotency keys, and when not?
  5. 05What you need
  6. 06Step 1: Derive the key from the business action
  7. 07Step 2: Enforce the key where the side effect happens
  8. 08Step 3: Tell the model what happened
  9. 09Step 4: Test the retries without a model
  10. 10The complete example
  11. 11What retries does Promptise Foundry do for you?
  12. 12Running idempotent tools in production
  13. 13Common problems and fixes
  14. 14Limitations
  15. 15FAQ
  16. 16Next steps

Idempotent tool calls are tool calls that are safe to repeat: the second call with the same intent changes nothing and returns the first result. AI agents need them because agents retry from more places than ordinary programs, and the model itself is one of them. This guide shows where agent retries come from, why a timeout is the dangerous case, and how to build idempotency keys into your tools, with a runnable Python example in which an agent bills a customer three times and the fixed version bills once.

[01]

How do you make AI agent tool calls safe to retry?

Give every tool that changes the outside world an idempotency key derived from the business action, such as invoice:acme:2026-10, and make the system that performs the action enforce it. The first call reserves the key and does the work. A repeat with the same key returns the first result, or "still in progress", and does nothing. For a call whose outcome is unknown, such as a timeout, give the model a status tool so it checks before it acts again.

  • The model is a retry layer. After a timeout or an unclear error, it calls the tool again, with a new tool call ID and sometimes reworded arguments.

  • A timeout is not a failure. The work can still be running, and it often finishes.

  • Derive the key from what the action means, not from the attempt: no timestamps, no random values per call, no tool call IDs.

  • Check the key where the side effect happens: the provider's idempotency header, or a unique constraint in your own database.

  • Reads need no key. Creates, sends, payments and anything that counts do.


[02]

Why do AI agents run the same action twice?

A classic API client has one retry loop. An agent stack has several, stacked on top of each other, and each one can repeat a tool call without knowing that another layer already did:

Six layers that can repeat one action: a person who sends the same message again, a webhook sender or chat service that redelivers, the agent runtime that retries runs and redelivers dead letters, the model that calls a tool again after a timeout or error, the tool wrapper whose timeout returns while the call keeps running, and the HTTP client that resends a request. All of them end at the system of record, where the idempotency key is checked.
FigureWhere an agent's retries come from: six layers can repeat one action
  • People press send twice, or ask again because the answer was slow.

  • Event senders redeliver. Webhook providers and chat services repeat a delivery that didn't get a 2xx answer.

  • The agent runtime retries a failed run, redelivers dead-lettered events, or restarts a process after a crash. A run retry repeats the whole turn, including tools that already succeeded.

  • The model repeats calls. If a tool times out, returns an error that says "retry later", or returns something the model doesn't trust, it calls the tool again. It also loses track of earlier calls when the context is trimmed.

  • The tool wrapper gives up after its timeout while the work underneath keeps going.

  • HTTP clients and SDKs resend requests after a dropped connection.

Retries are not the bug. They are how distributed systems recover. The bug is a tool that can't tell a retry from a new request.


[03]

What happens when a tool call times out?

A timeout tells you one thing: the caller stopped waiting. It doesn't tell you whether the work happened. Here is an invoicing agent with a tool that gives up after 2 seconds, in front of a billing API that takes 3:

A sequence between the model, a tool with a 2 second timeout and a billing API that takes 3 seconds. The tool answers TIMEOUT, retryable while the billing API is still sending invoice 1. The model calls send_invoice again. Without a key the second call creates invoice 2; with the key invoice:acme:2026-10 the API answers in progress or returns the first invoice.
FigureA tool timeout is an unknown outcome: the first call keeps running while the model retries

The stand-in billing API is a SQLite ledger with a 3-second delay. It accepts an optional idempotency key, the way Stripe's Idempotency-Key header works:

Pythonbilling.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
"""A stand-in for a billing provider: slow, with optional idempotency keys that
work like Stripe's Idempotency-Key header. The ledger is a SQLite file."""
import sqlite3
import time
import uuid

LEDGER = "ledger.db"


class Conflict(Exception):
    """Another request with the same idempotency key is still running (HTTP 409)."""


def _db() -> sqlite3.Connection:
    conn = sqlite3.connect(LEDGER, timeout=10)
    conn.execute(
        "CREATE TABLE IF NOT EXISTS invoices (id TEXT PRIMARY KEY, customer TEXT, amount REAL,"
        " memo TEXT, status TEXT, idempotency_key TEXT UNIQUE)"
    )
    return conn


def create_invoice(customer: str, amount: float, memo: str, idempotency_key: str | None = None) -> dict:
    """Create and send an invoice. Takes about 3 seconds."""
    invoice_id = "inv_" + uuid.uuid4().hex[:8]
    with _db() as conn:
        if idempotency_key:
            row = conn.execute(
                "SELECT id, customer, amount, memo, status FROM invoices WHERE idempotency_key = ?",
                (idempotency_key,),
            ).fetchone()
            if row and row[4] == "processing":
                raise Conflict(f"request {idempotency_key} is still being processed")
            if row:  # a retry of a finished request: return the first result, do nothing
                return {"id": row[0], "customer": row[1], "amount": row[2], "memo": row[3], "replayed": True}
        try:  # reserve the key before doing the work; the UNIQUE constraint settles races
            conn.execute(
                "INSERT INTO invoices VALUES (?, ?, ?, ?, 'processing', ?)",
                (invoice_id, customer, amount, memo, idempotency_key),
            )
        except sqlite3.IntegrityError:
            raise Conflict(f"request {idempotency_key} is already being processed") from None
    time.sleep(3)  # the slow part: PDF, email, accounting sync
    with _db() as conn:
        conn.execute("UPDATE invoices SET status = 'sent' WHERE id = ?", (invoice_id,))
    return {"id": invoice_id, "customer": customer, "amount": amount, "memo": memo}


def find_invoice(idempotency_key: str) -> dict | None:
    with _db() as conn:
        row = conn.execute(
            "SELECT id, amount, status FROM invoices WHERE idempotency_key = ?", (idempotency_key,)
        ).fetchone()
    return dict(zip(("id", "amount", "status"), row)) if row else None


def ledger() -> list[str]:
    with _db() as conn:
        rows = conn.execute("SELECT id, customer, amount, memo, status FROM invoices").fetchall()
    return [f"{r[0]} {r[1]} {r[2]:.2f} EUR {r[3]!r} ({r[4]})" for r in rows]


def reset() -> None:
    with _db() as conn:
        conn.execute("DELETE FROM invoices")

The first version of the agent calls it without a key:

Pythonagent_naive.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
"""An invoicing agent whose tool times out while the invoice is still being sent."""
import asyncio
import json

from promptise import build_agent, tool

import billing


@tool(timeout=2)
def send_invoice(customer: str, amount: float, memo: str) -> dict:
    """Create and send an invoice to a customer.

    Args:
        customer: Customer ID, for example "acme".
        amount: Amount in EUR.
        memo: What the invoice is for.
    """
    return billing.create_invoice(customer, amount, memo)


async def main() -> None:
    billing.reset()
    agent = await build_agent(model="openai:gpt-5-mini", extra_tools=[send_invoice])
    result = await agent.ainvoke(
        {"messages": [{"role": "user", "content": "Invoice acme 120 EUR for October support."}]}
    )
    for message in result["messages"]:
        for call in getattr(message, "tool_calls", None) or []:
            print("tool call:", call["name"], json.dumps(call["args"]))
        if message.type == "tool":
            print("tool result:", message.content.replace("\n", " ")[:160])
    print("agent:", result["messages"][-1].content.split("\n\n")[0], "[...]")
    await asyncio.sleep(4)  # let calls that timed out finish in the background
    print("ledger:", *billing.ledger(), sep="\n  ")
    await agent.shutdown()


asyncio.run(main())
Output
tool call: send_invoice {"customer": "acme", "amount": 120, "memo": "Support for October"}
tool result: {   "error": {     "code": "TIMEOUT",     "message": "Tool 'send_invoice' timed out after 2.0s",     "retryable": true,     "suggestion": "The tool did not fini
tool call: send_invoice {"customer": "acme", "amount": 120, "memo": "Support for October"}
tool result: {   "error": {     "code": "TIMEOUT",     "message": "Tool 'send_invoice' timed out after 2.0s",     "retryable": true,     "suggestion": "The tool did not fini
tool call: send_invoice {"customer": "acme", "amount": 120, "memo": "Support for October"}
tool result: {   "error": {     "code": "TIMEOUT",     "message": "Tool 'send_invoice' timed out after 2.0s",     "retryable": true,     "suggestion": "The tool did not fini
agent: I tried to send the invoice but the invoicing tool timed out (retryable). Details I attempted to send:
- Customer: acme
- Amount: 120 EUR
- Memo: "Support for October" [...]
ledger:
  inv_61040495 acme 120.00 EUR 'Support for October' (sent)
  inv_d9881557 acme 120.00 EUR 'Support for October' (sent)
  inv_3d4356cb acme 120.00 EUR 'Support for October' (sent)

The model called send_invoice three times, got TIMEOUT three times, and told the user it couldn't send the invoice. The ledger has three invoices. Every call timed out from the agent's point of view, and every call succeeded from the customer's.

Two details in the output explain it. The error says "retryable": true and suggests trying again later, which is true for a read and dangerous for a write. And the tool is a plain def, so Promptise runs it in a worker thread that can't be stopped: the tool docs say a timed-out sync tool "finishes in the background and its result is dropped." An async tool is cancelled instead, but cancelling your side doesn't cancel a request the billing API already received.


[04]

When do you need idempotency keys, and when not?

Tool does

Example

Safe to repeat?

What to do

Reads

get_order, search_docs

Yes

Nothing; retry freely

Sets a value

set_status(order, "shipped")

Yes, if nothing else changed it in between

Usually nothing; use a version check if it matters

Creates something

create_ticket, add_reminder

No

Key per business object

Sends something

send_email, post_message

No

Key per message, checked before sending

Moves money or counts

charge, refund, increment_quota

No

Key, always, plus approval for large amounts

Starts a long job

start_export

No

Key the start, then poll a status tool

If the tool's effect stays the same however often it runs, you don't need a key. Most tools that matter aren't like that.


[05]

What you need

  • Python 3.10 or later and Promptise Foundry 1.6.0 for the examples.

  • An OpenAI API key for the two agent runs. The retry test needs no model.

  • A system of record that can enforce uniqueness: a provider that accepts idempotency keys, or a database table with a unique constraint.

>_Terminal
uv venv && uv pip install "promptise==1.6.0"

[06]

Step 1: Derive the key from the business action

A good key names one action in your business and is the same on every attempt. For invoices, "one invoice per customer per billing period" is the rule, so the key is invoice:<customer>:<period>:

Python
def invoice_key(customer: str, period: str) -> str:
    return f"invoice:{customer.strip().lower()}:{period}"

Three choices in that line matter:

  • Not the tool call ID. Model providers give each tool call an ID, and some guides derive keys from it. When the model re-issues a call after a timeout, it is a new tool call with a new ID, so that key misses exactly the retries you care about.

  • Not every argument. The memo is free text the model may word differently on the next try ("October support", then "Support, October 2026"). A hash of all arguments would treat that as a new invoice. Key on the fields that identify the action, and normalize them.

  • A parameter that makes the action explicit. The naive tool had no period, so "the October invoice" existed only in the memo. Adding period to the tool's schema gives the key something stable to use, and the model fills it.

When no business identity exists, such as "send this reminder email now", create a request ID once, before the first attempt, and store it with the work item. In an agent runtime, the trigger event's ID is such a value: in Promptise, an event that is retried or redelivered keeps its event_id.

Keep personal data out of keys. Stripe's docs advise against email addresses or personal identifiers as idempotency keys, because keys end up in logs.


[07]

Step 2: Enforce the key where the side effect happens

The check belongs in the system that performs the action, not in the agent's memory. The agent's journal or conversation is written after the tool returns, so it knows nothing about a call that timed out or crashed halfway.

You have two places to enforce it:

  • The provider supports keys. Send yours. Stripe, for example, saves the status code and body of the first request with a key and returns them for later requests with the same key. It compares the parameters and returns an error if they differ, and keys can be pruned once they are at least 24 hours old, so a retry days later is a new request.

  • It doesn't. Keep your own table: insert the key with status processing and a unique constraint before you do the work, mark it done afterwards, and return the stored result for a repeat.

billing.py above does the second, and shows two details that are easy to get wrong:

  • Reserve the key before the slow work. If the key is written only after the invoice is sent, a retry that arrives during the 3 seconds sees nothing and sends a second invoice. That is exactly the timeout case.

  • Let the unique constraint decide races. Two concurrent calls can both read "no such key" before either writes it. The INSERT with a UNIQUE column lets exactly one win; the other gets a conflict. A check-then-insert without the constraint has a race.


[08]

Step 3: Tell the model what happened

The tool now gets one of three answers from the billing API: a new invoice, a replay of the first one, or "still in progress". It turns each into something the model can act on:

Pythonagent_keyed.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
"""The same agent with an idempotent tool: a key derived from the business action,
sent to the provider, and a status tool for unknown outcomes."""
import asyncio
import json

from promptise import ToolError, build_agent, tool

import billing


def invoice_key(customer: str, period: str) -> str:
    """One invoice per customer and billing period: the same action always gets the same key."""
    return f"invoice:{customer.strip().lower()}:{period}"


@tool(timeout=2)
def send_invoice(customer: str, period: str, amount: float, memo: str) -> dict:
    """Create and send the invoice for one customer and billing period.

    Safe to retry: a second call for the same customer and period never sends a
    second invoice.

    Args:
        customer: Customer ID, for example "acme".
        period: Billing period as YYYY-MM, for example "2026-10".
        amount: Amount in EUR.
        memo: What the invoice is for.
    """
    key = invoice_key(customer, period)
    try:
        invoice = billing.create_invoice(customer, amount, memo, idempotency_key=key)
    except billing.Conflict:
        raise ToolError(
            f"The invoice for {customer} {period} is still being sent.",
            code="IN_PROGRESS",
            retryable=False,
            suggestion="Don't send it again. Check invoice_status in a few seconds.",
        ) from None
    if invoice.get("replayed") and invoice["amount"] != amount:
        raise ToolError(
            f"{customer} already has an invoice for {period} over {invoice['amount']:.2f} EUR.",
            code="CONFLICT",
            suggestion="Ask the user before changing or adding an invoice.",
        )
    return invoice


@tool
def invoice_status(customer: str, period: str) -> dict:
    """Check whether the invoice for a customer and billing period exists and was sent."""
    found = billing.find_invoice(invoice_key(customer, period))
    return found or {"status": "not found"}


async def main() -> None:
    billing.reset()
    agent = await build_agent(
        model="openai:gpt-5-mini",
        extra_tools=[send_invoice, invoice_status],
        instructions=(
            "You send invoices. If a call that changes something times out, its outcome is "
            "unknown: check invoice_status before you call it again. Reply in one plain sentence."
        ),
    )
    result = await agent.ainvoke(
        {"messages": [{"role": "user", "content": "Invoice acme 120 EUR for October 2026 support."}]}
    )
    for message in result["messages"]:
        for call in getattr(message, "tool_calls", None) or []:
            print("tool call:", call["name"], json.dumps(call["args"]))
        if message.type == "tool":
            print("tool result:", " ".join(message.content.split())[:150])
    print("agent:", result["messages"][-1].content.split("\n\n")[0])
    await asyncio.sleep(4)
    print("ledger:", *billing.ledger(), sep="\n  ")
    await agent.shutdown()


if __name__ == "__main__":
    asyncio.run(main())
  • In progress is not retryable. The IN_PROGRESS error says retryable=False and suggests invoice_status. Sending again can't help; checking can.

  • A replay with different arguments is a conflict, not a success. If the model asks for 150 EUR and the period already has a 120 EUR invoice, the tool returns CONFLICT and asks the model to check with the user, instead of silently returning the old invoice.

  • A status tool resolves unknown outcomes. invoice_status is a read, so the model can call it as often as it likes.

  • The description says it is safe to retry. The model reads tool descriptions; "a second call never sends a second invoice" stops it from inventing workarounds.


[09]

Step 4: Test the retries without a model

Each retry path is a plain function call, so you can test them all without an LLM. ainvoke runs the tool the way the agent does, with validation and the 2-second timeout:

Pythonretry_test.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
"""Retry the keyed tool the ways an agent stack does, without a model, and count invoices."""
import asyncio

import billing
from agent_keyed import send_invoice

ARGS = {"customer": "acme", "period": "2026-10", "amount": 120, "memo": "October support"}


async def call(args: dict) -> str:
    try:
        return await send_invoice.ainvoke(args)  # validated and timed, as the agent runs it
    except Exception as exc:  # a ToolError reaches callers as FunctionToolError
        return f"{type(exc).__name__}: {exc}"


async def main() -> None:
    billing.reset()
    print("1 first call:      ", await call(ARGS))
    print("2 retry at once:   ", await call(ARGS))
    await asyncio.sleep(3.5)
    print("3 retry later:     ", await call({**ARGS, "memo": "Support, October 2026"}))
    print("4 other amount:    ", await call({**ARGS, "amount": 150}))
    print("5 two at once:     ", await asyncio.gather(call({**ARGS, "period": "2026-11"}),
                                                     call({**ARGS, "period": "2026-11"})))
    await asyncio.sleep(3.5)
    print("ledger:", *billing.ledger(), sep="\n  ")


asyncio.run(main())
Output
1 first call:       FunctionToolError: TIMEOUT: Tool 'send_invoice' timed out after 2.0s
2 retry at once:    FunctionToolError: IN_PROGRESS: The invoice for acme 2026-10 is still being sent.
3 retry later:      {"id": "inv_4b0c2526", "customer": "acme", "amount": 120.0, "memo": "October support", "replayed": true}
4 other amount:     FunctionToolError: CONFLICT: acme already has an invoice for 2026-10 over 120.00 EUR.
5 two at once:      ["FunctionToolError: TIMEOUT: Tool 'send_invoice' timed out after 2.0s", 'FunctionToolError: IN_PROGRESS: The invoice for acme 2026-11 is still being sent.']
ledger:
  inv_4b0c2526 acme 120.00 EUR 'October support' (sent)
  inv_1ebc1dd5 acme 120.00 EUR 'October support' (sent)

Five ways to call the same action, two invoices, one per period. Line 1 is the dangerous case from before: a timeout while the invoice is being sent. Line 2 is the model retrying at once and getting IN_PROGRESS. Line 3 retries later with a reworded memo and gets the first invoice back, marked replayed. Line 4 asks for a different amount and gets CONFLICT. Line 5 sends two calls at the same moment for November; one wins, the other is told the invoice is in progress.

Keep tests like these in CI for every tool with side effects: timeout after success, immediate retry, retry after completion, changed arguments and concurrent duplicates. Each should end with exactly one effect.


[10]

The complete example

The same request to the fixed agent:

Output
tool call: send_invoice {"customer": "acme", "period": "2026-10", "amount": 120, "memo": "October 2026 support"}
tool result: { "error": { "code": "TIMEOUT", "message": "Tool 'send_invoice' timed out after 2.0s", "retryable": true, "suggestion": "The tool did not finish withi
tool call: invoice_status {"customer": "acme", "period": "2026-10"}
tool result: {"id": "inv_80152f99", "amount": 120.0, "status": "sent"}
agent: Invoice for acme, period 2026-10, amount €120 has been sent.
ledger:
  inv_80152f99 acme 120.00 EUR 'October 2026 support' (sent)

The call timed out again, because the billing API is just as slow. This time the model followed its instruction, checked invoice_status, found the invoice already sent and told the user so. The ledger has one invoice. Had the model called send_invoice again instead, the key would have answered "in progress" or replayed the first invoice: the instruction makes the agent faster and clearer, and the key makes it correct.


[11]

What retries does Promptise Foundry do for you?

Knowing which layers retry on their own tells you where keys are required. In Foundry 1.6.0:

Layer

Retries?

What it means for your tools

@tool(timeout=...)

Returns a retryable TIMEOUT; a sync function keeps running

The model may call again while the first call is still working

MCP client (auto_reconnect)

Re-sends a request only after a 404 for a lost session, which the server never ran

Safe by design; a connection that drops during a call is not retried

TriggerDeliveryConfig.max_retries

Off by default; when on, re-runs the whole agent turn

Every tool in the turn runs again; the docs say to enable it only for idempotent or approval-gated tools

redeliver_dead_letters()

Puts events back on the queue with the same event_id

Keys derived from the event ID stay stable

Webhook trigger

Answers 503 with Retry-After when the queue is full

The sender retries; dedupe by the sender's delivery ID

Journal recovery

Rebuilds state; never re-runs tools

A call cut off by a crash is lost or done, and you don't know which; see durable AI agents

@server.tool(idempotent_hint=True)

Advertises an MCP annotation

A hint for clients and approval reviewers, not enforcement

The MCP spec is explicit about that last row: all tool annotations are hints, and clients should not make decisions based on annotations from untrusted servers. Declaring idempotentHint is useful documentation. The key is what makes it true.


[12]

Running idempotent tools in production

  • Keep keys at least as long as retries can happen. Model retries take seconds, webhook redeliveries hours, dead-letter redelivery days. If your provider prunes keys after 24 hours, keep your own record of what was sent for longer.

  • Expire stuck reservations carefully. A process that crashes after reserving a key leaves it processing forever, and every retry gets "in progress". Give reservations a lease: after a few minutes, check the provider for the real outcome, then mark the key done or release it. Never release it blindly, or the crash becomes a duplicate.

  • Write the key and the effect in one transaction when the effect is in your own database. Two separate writes reopen the gap between them.

  • Handle multi-step tools step by step. A tool that creates an invoice and then emails it needs a key per step (invoice:acme:2026-10, invoice-email:acme:2026-10), so a retry skips the finished step and does the missing one.

  • Count replays. A rising rate of replayed or in-progress answers is an early sign of timeouts set too short or a model that retries too eagerly. Log them as metrics.

  • Set timeouts above the provider's normal latency. A key makes a too-short timeout harmless, but it still wastes calls and confuses the model.

  • Gate what keys can't fix. A key stops a repeat; it doesn't stop a wrong first action. Put payments and deletions behind human approval and an irreversible-action budget.


[13]

Common problems and fixes

Symptom

Cause

Fix

A customer got two emails or invoices

A timed-out call finished, and the model or a retry layer called again

Key the action and enforce it in the system of record

The key didn't catch the repeat

Key built from a timestamp, a random value or the tool call ID

Derive it from the business action or a request ID created once

A reworded retry created a duplicate

Key hashes free-text arguments

Key only the fields that identify the action; normalize them

Two concurrent calls both went through

Check-then-insert without a unique constraint

Reserve with an INSERT on a unique column

Every retry says "in progress" forever

A crash left a reservation in processing

Lease reservations; reconcile with the provider before releasing

A changed request returned the old result

Replay ignored the arguments

Compare the stored request with the new one; return a conflict

A run retry repeated finished tools

trigger_delivery.max_retries re-runs the whole turn

Make every side-effecting tool idempotent first, or leave retries off

The agent tells the user it failed when it succeeded

The model read TIMEOUT as failure

Add a status tool and an instruction to check unknown outcomes


[14]

Limitations

  • Idempotency stops repeats, not mistakes. A wrong first invoice is still wrong.

  • Some actions can't be checked. A phone call or an SMS through a provider without deduplication has no "did it happen?" query. Keep your own record before sending, and accept that a crash in between leaves you unsure.

  • Keys depend on stable arguments. If the model picks a different customer ID or period on a retry, the key changes. Validate identifiers against your data before acting.

  • Foundry doesn't generate keys for you. Keys are tool code. Foundry's part is not retrying unsafely on its own and keeping event IDs stable.


[15]

FAQ

What is an idempotent tool call?

An idempotent tool call is one you can repeat without changing the result: the second call with the same intent has no extra effect and returns what the first returned. Reads are idempotent by nature. Tools that create, send or pay become idempotent through an idempotency key that the system performing the action checks.

Why does my AI agent run the same action twice?

Usually because a call timed out or returned an unclear error, and the model, the runtime or a client retried while the first call was still running or had already succeeded. Agent stacks retry at several layers, and the model is one of them. Without an idempotency key, each retry is a new action.

How do I generate an idempotency key for an agent tool?

Derive it from the business action the tool performs, such as invoice:<customer>:<period> or refund:<order_id>, and normalize the parts. If there is no natural identity, create a request ID once before the first attempt and store it with the work. Don't use timestamps, per-attempt random values or the model's tool call ID, which change on every retry.

Is it safe to retry a tool call after a timeout?

Only if the tool is idempotent. A timeout means the caller stopped waiting, not that the work failed; the request may still be running or may have finished. Retry with the same idempotency key, or first check the outcome with a read-only status tool.

Do durable execution frameworks make tool calls idempotent?

No. They record finished steps so they don't run again after a crash, but a step that was running when the crash happened is retried, and its side effect may already have happened. They still need idempotent steps. The key in the call to the outside system is what makes the repeat harmless.

Does the MCP idempotentHint annotation make a tool idempotent?

No. It is a hint a server sends with the tool's description, telling clients that repeated calls with the same arguments have no additional effect. The MCP spec says all annotations are hints and not guaranteed. The tool's own key check is what makes the hint true.


[16]

Next steps

  • Durable AI agents covers crash recovery with a journal, the other half of exactly-once.

  • AI agent retries and a dead letter queue explains run retries and redelivery in the runtime.

  • MCP long-running tasks shows the start-then-poll pattern for slow work.

  • Build a personal AI agent uses idempotent reply and reminder tools end to end.

  • What is tool calling? explains how the model requests tool calls in the first place.

Contents

  1. 01How do you make AI agent tool calls safe to retry?
  2. 02Why do AI agents run the same action twice?
  3. 03What happens when a tool call times out?
  4. 04When do you need idempotency keys, and when not?
  5. 05What you need
  6. 06Step 1: Derive the key from the business action
  7. 07Step 2: Enforce the key where the side effect happens
  8. 08Step 3: Tell the model what happened
  9. 09Step 4: Test the retries without a model
  10. 10The complete example
  11. 11What retries does Promptise Foundry do for you?
  12. 12Running idempotent tools in production
  13. 13Common problems and fixes
  14. 14Limitations
  15. 15FAQ
  16. 16Next steps

Learning paths

Want more structure? Paths put guides in order, like a short course.

See the paths →

> Tools and the Model Context Protocol (MCP)

Related guides

All guides in this topic →
  • // 01Intermediate · 16 minAI Agent Tool Failures: Stop Unsafe WorkaroundsWhen a tool fails, AI agents improvise and reach the same data another way. Stop unsafe workarounds with clear errors, narrow tools and deny rules.
  • // 02Intermediate · 23 minMCP Server Caching, Timeouts and Circuit Breakers in PythonKeep an MCP server fast when its API is flaky: caching, retries with backoff, timeouts, a circuit breaker and limits, with real before and after numbers.
  • // 03Intermediate · 18 minMCP Middleware for Servers: Auth, Logging, Rate LimitsMCP middleware adds auth, audit logs, rate limits, caching and timeouts to every tool call. How the pipeline and ordering work in FastMCP and Promptise.
  • // 04Intermediate · 20 minMCP vs CLI for AI Agents: Tokens, Security, When to Use EachMCP vs CLI tools for AI agents: where the tokens go, prompt caching, per-user security and when to use each or both, with runnable code and real output.
  • // 05Intermediate · 16 minAgent Skills vs MCP Tools: When to Use EachAgent Skills vs MCP tools: what SKILL.md skills and MCP servers are, how each uses context and permissions, when to use which, and an agent using both.
  • // 06Beginner · 16 minLangChain vs MCP: Differences and How They Work TogetherLangChain vs MCP: MCP is a protocol that serves tools, LangChain is an agent framework. How they differ, when to use each, and a runnable MCP agent.

Keep going.

Browse every guide by topic and level, or follow a learning path that puts them in order.

All guidesLearning paths