An AI agent tool timeout is not a failure: check the idempotency contract before any retry

A timed-out write can end in no effect, one effect or a pending one, and the timeout cannot tell you which. A test matrix for retry, reconcile or escalate, with a hypothetical refund tool and its stated contract.

Written by Worksi's AI marketing agents and checked by a separate AI fact-checking agent.

How this blog is made
A vending machine coil has stopped mid-turn, leaving an orange cube teetering over an empty tray while a fresh coin waits at the intake slot.
Illustration made with AI
In this article
  1. Three layers each "retry" something different
  2. A timeout measures your clock, not their commit
  3. Classify the outcome before deciding anything
  4. A stable key for one logical action
  5. The test matrix
  6. The executor enforces, the message reports
  7. A trace is evidence, not the transaction
  8. When a person decides

Here is a hypothetical. A support agent has a tool, issue_refund(order_id, amount). The wrapper calls a payment service with a 30-second timeout. The timeout fires. The wrapper returns an error string, the model reads "refund failed", and it calls the tool again. The customer now has two refunds, or one, or none, and nothing in the agent's context can tell you which.

The claim of this article: an AI agent tool timeout is a third outcome, "unknown", and the decision to retry belongs to the downstream system's idempotency contract, not to the agent loop or the framework's retry policy. Before any retry you need three things: what that contract promises for a repeated request, whether the original request can still be running, and what reconciliation evidence you have. Below is a test matrix that makes those checks concrete.

Three layers each "retry" something different#

When people say "the agent retried", they can mean three unrelated things.

Workflow replay. A durable execution engine rebuilds workflow state from recorded history. In Temporal's MCP integration, which its docs mark as pre-release, "completed operation results are recorded in Event History and reused during Workflow replay" (Temporal MCP integration). Replay of a completed operation does not call the tool again.

Tool-call retry. One layer down, the same page says "an Activity can execute more than once, such as when a Worker loses the response after the MCP server has completed a tool call", and that an Activity timeout "does not cancel an MCP request already running on the Worker". Its advice: make tools with side effects idempotent. An agent adds a second kind of tool-call retry with no policy at all: the model reading an error and deciding to call the tool again, possibly with different arguments.

Downstream mutation. The refund itself, in the payment service's records. This is the only layer that knows whether money moved, and the one the caller cannot see when the response is lost.

Model calls that do not themselves execute side-effecting tools are a separate case. Retrying one before the first streamed byte can repeat a generation, and costs tokens again if the provider had already generated them; after the first token, the client owns the failure. Provider-executed tools, and writes your code completed earlier in the run, keep the same unknown-outcome and reconciliation requirements as everything below.

A timeout measures your clock, not their commit#

A timeout is a decision your process made about how long to wait. When it fires, the downstream request may be in any of these states:

  1. It did not arrive.
  2. It arrived and was rejected before execution.
  3. It is still executing.
  4. It committed, and the response was lost.
  5. It will commit later, after you have stopped waiting.

The last one is the trap. Suppose the wrapper, after the timeout, reads back "refunds for order 1042" and finds none. It concludes the refund failed and sends a fresh one. Two seconds later the original request, queued behind a slow lock, commits. The read-back was true when it ran and wrong as a basis for the decision.

Checking once is a snapshot. An empty read-back shows non-commit only when the original request can no longer commit: it was confirmed cancelled, or the contract bounds how long it can run. The AWS Builders' Library article on idempotent APIs makes the same point from the service side: after a timeout the caller has to reconcile, and "even in the case of a reconciliation workflow, there might still be some uncertainty" (AWS Builders' Library).

Classify the outcome before deciding anything#

First, define what "done" means for this tool. In our hypothetical, the payment service works like Stripe's API v1 refunds: creating a refund returns an object whose status "can be pending, requires_action, succeeded, failed, or canceled" (Stripe Refund object). An object id means the request was recorded. It does not mean the money was refunded.

Every tool call that writes then ends in one of four recorded classes.

Known failure before execution. Evidence that the downstream did not start the work: your code refused before sending, or the downstream returned a response its contract defines as "not executed". Stripe v1 documents such a case: when parameters fail validation, or the request conflicts with a concurrent one using the same key, "we don't save the idempotent result because no API endpoint initiates the execution. You can retry these requests" (Stripe idempotent requests).

Recorded, not terminal. An object exists, linked to this logical action, in a non-terminal state such as pending. Do not send again. Wait for the documented terminal state, by polling the object or through the provider's events.

Known terminal outcome. The object reached a documented terminal state, and its target and amount match the intent. succeeded is completion; failed or canceled is a known failure after execution. A found object, linked to this action by the key or by metadata you attached, shows the effect for that object. It does not show the effect happened only once.

Unknown. Timeout, connection reset after the request was sent, a server error, a worker crash between send and record. This is the default for anything that is not positively one of the other three.

Unknown allows exactly two moves: retry the same logical action under a contract that makes the retry safe, or reconcile and, if reconciliation cannot settle it, hand it to a person.

A stable key for one logical action#

The usual tool is an idempotency key: a caller-generated id that lets the server recognize a repeat. The details decide whether it works.

Mint the key from the intent, once, in your code. Generate it when the refund intent is recorded, store it durably before the first send, and reuse it on every attempt and every replay. Never let the model generate it, and do not derive it from a hash of the parameters. The AWS article explains why a parameter hash fails: two identical requests can be two intended actions. A customer owed two partial refunds of the same amount is the refund version of that.

Read the contract for what a repeat returns. Providers differ, and so do API versions of one provider. Stripe's reference says that under v1 it saves the first response "after endpoint execution begins" and that retries with the same key "return the saved response, including 500 errors". Under v2, "retries don't repeat successful operations. Stripe retries failed or partially failed work to completion when possible and returns an updated response." So a v1 retry after a saved 500 replays the 500. It tells you what the first attempt returned, not whether anything was written. Stripe's advanced error-handling guide says to treat a 500 as indeterminate and not to retry with a new key until the original outcome is resolved. Do not assume another provider behaves like either version.

Changed parameters are a different request. Stripe v1 returns an error when a reused key arrives with different parameters, and AWS gives a validation error in the same case, because the caller may have meant a different outcome. In an agent this happens when the model re-issues the call with 40.00 instead of 45.00. Treat that error as a signal to stop, not something to route around with a fresh key.

Concurrent retries. Two workers can send the same key while the first attempt is still executing. Stripe's error guide says that when a 409 indicates another request with the same key is still executing, you wait and retry with that key: "A new key can start a second operation." The rejection is not a failure.

Key expiry. Stripe retains v1 keys "for at least 24 hours and can remove them after that", and reusing a removed key "can execute a new request". Its guide says to limit retries of an unresolved operation to the first 24 hours, and after that to retry with neither the old key nor a new one, but to reconcile. AWS describes retention for EC2 instances as the resource's lifetime plus an interval after which late requests would have arrived or no longer be valid. Your retry window must end before the downstream's retention does.

If you own the downstream. The AWS article states the requirement: recording the token and every mutation for the request must be one atomic, ACID operation, or you can record the key and miss the write, or write and miss the key.

With all of that in place, the contract prevents additional effects for one key within its retention window. It does not make an effect happen. A retry can end in a permanent rejection with zero effects, or keep replaying a saved server error while the outcome stays open; of its incident recovery, Stripe says "reconciliation isn't guaranteed to resolve every operation". One effect is established only when you confirm the documented success state independently, by reading the object. That is a property of this contract, for this window, not exactly-once in general. When no such contract exists, no client code produces one.

The test matrix#

Run these against a stub of the hypothetical payment service, behind a proxy that can delay or drop responses. The stub implements a Stripe-v1-like contract with five stated assumptions:

  • A. After execution begins, a repeat with the same key and parameters returns the saved response, server errors included.
  • B. A same-key request arriving while the first is executing gets a conflict and is not saved.
  • C. A reused key with different parameters is rejected.
  • D. Keys are kept for a configurable window, then pruned.
  • E. Refunds are asynchronous, with documented terminal states.

For a different contract, replace the response assertions with what that contract documents: under Stripe v2, for instance, a retry may return an updated response rather than the saved one. The assertion that holds for every contract: no unintended second mutation. Each row checks the stub's records, not what the tool returned.

Scenario How to produce it Evidence to collect Expected decision
Rejected before execution Send an invalid amount Validation error; no refund object Known failure. Fix the input; retry the same logical action
Response lost after commit Proxy drops the success response Timeout on your side; one refund object carrying the key Unknown. Retry with the same key inside retention; assert the saved response (A) and one object
Refund still pending Stub returns pending, later failed Object id with a non-terminal, then terminal status Recorded, not terminal. No resend; on failed, known failure, and any new refund is a new intent with a new key
Original still running Stub holds the request beyond your timeout, then commits Empty read-back at time T; object appears after T Unknown. An empty read-back must not mark failure; assert one object at the end
Concurrent duplicates Two workers send the same key at once One executes; the other gets a conflict (B) Conflict is not failure; wait, retry the same key; assert one object
Same key, changed parameters Re-issue with a different amount Parameter-mismatch error (C) Stop. Escalate to a person with both requests
Retry after key expiry Stub prunes keys after a short retention (D) A second object appears if you retry Your code refuses the retry past its window and reconciles instead
Two intended actions Customer is owed two equal partial refunds Two intents, two keys Two objects; assert your dedupe does not merge them
No idempotency contract Downstream accepts no key Timeout and nothing else Record unknown, block automatic retry, escalate

The rows that fail most quietly are "original still running" and "retry after key expiry": both pass when you test only the happy path and the lost-response path.

The executor enforces, the message reports#

The model does not apply the classification, and a message cannot enforce it. After a tool result the model can answer or make more tool calls (OpenAI function calling); if it reads "failed", it may try again, with different arguments. So the block on further writes lives in durable state your executor checks before every send. A sketch, illustrative only, with a hypothetical refunds client:

Python
def issue_refund(intent):
    # The key was minted when the intent was recorded, never by the model.
    # The executor enforces the block; the returned text only reports it.
    if intent.state in ("unknown", "pending", "escalated", "completed"):
        return f"Refund {intent.id} is {intent.state}. Not sent again."
    if intent.first_attempt_at and now() - intent.first_attempt_at > SAFE_RETRY_WINDOW:
        return escalate(intent, reason="past key retention window")
    try:
        resp = refunds.create(
            order_id=intent.order_id,
            amount=intent.amount,
            idempotency_key=intent.idempotency_key,
            metadata={"intent_id": intent.id},
            timeout=30,
        )
    except ValidationError as e:
        return record(intent, "failed_before_execution", evidence=e)
    except (Timeout, ConnectionReset, ServerError) as e:
        record(intent, "unknown", evidence=e)
        return "Refund outcome unknown. Reconciliation is pending."
    if resp.status == "succeeded" and resp.amount == intent.amount:
        return record(intent, "completed", evidence=resp.id)
    if resp.status in ("failed", "canceled"):
        return record(intent, "failed_after_execution", evidence=resp.id)
    return record(intent, "pending", evidence=resp.id)

A separate reconciler, not the model, retries unknown intents with the same key inside SAFE_RETRY_WINDOW, reads pending objects until they reach a terminal state, and escalates the rest. SAFE_RETRY_WINDOW comes from the downstream's documented key retention, minus a margin you choose.

A trace is evidence, not the transaction#

An instrumented tool span records the metadata you captured: when the call started, how long it waited, its status and error type, and, where your capture settings allow, the request and response content. Arguments or results that were redacted or never captured stay unknown; the span cannot reconstruct them. For a timeout, the span is evidence of not knowing. It cannot show whether the downstream committed: only the downstream's records can, the same gap described in a green tool span records the call, not whether the outcome is right.

The same applies to tooling around the agent. Worksi records tool calls as spans, and its gateway retries model calls, up to two retries and then your fallback models, never after the first streamed byte. Its recovery actions include a retry through a signed webhook that a person runs. None of these knows whether your refund committed, and none deduplicates it: a webhook handler that calls a side-effecting tool needs the same key and classification as the agent. Worksi does not provide exactly-once delivery of your side effects. It gives you the record of each attempt next to the run, which is what a person needs when a matrix row ends in "escalate". See how tracing works.

When a person decides#

Hand the action to a person when any of these hold: the downstream has no key contract; the retry window has passed; parameters changed under the same key; a v1-style contract keeps replaying a server error; or reconciliation finds zero objects while the original could still commit, or more than one object. Give them the intent id, the key, every attempt with its timestamp and error, each read-back with its timestamp and result, and the downstream ids and statuses found. That is less satisfying than a retry policy. It is also the only honest answer when nobody can show what happened to the money.

Sources

  1. Temporal: Model Context Protocol (MCP) integration, Python SDK
  2. Amazon Builders' Library: Making retries safe with idempotent APIs
  3. Stripe API reference: Idempotent requests
  4. Stripe documentation: Advanced error handling
  5. Stripe API reference: The Refund object
  6. OpenAI API: Function calling

How this blog is made

Worksi's AI marketing agents write the articles here, and a separate AI fact-checking agent checks each one before it is published. The sources an article cites are listed with it. Its illustration was made with AI. If you find a mistake, write to info@worksi.ai.