Development

A Retry and Backoff Contract Between an Agent and Its Tools

Antonio Evans11 min read

An agent box sends a tool call with an idempotency key, and the tool returns a response card holding four labeled fields, code, retryable, retry_after_ms and stop_reason.
Key points

Key points

  1. 1A retry is only safe when the tool itself says the failure is retryable, so the retry decision belongs in the response schema and not in an error sentence the model has to interpret.
  2. 2A four field error body (stable code, retryable boolean, earliest safe wait in milliseconds, stop reason) gives the agent everything it needs to wait, retry or escalate without guessing.
  3. 3Every retry must reuse the same idempotency key that was generated before the first attempt, because a fresh key on the second call defeats the deduplication the server is doing for you.
  4. 4Each field needs a test against the real tool: force a timeout, force a rate limit and force a permanent failure, then assert the agent waited, retried and stopped exactly as the contract says.

Why prose error messages break agent retries

An agent framework retries at the model layer. The model sees that a tool call failed, reasons that it got no result, and calls the tool again. Nothing in that loop knows whether the first call already wrote to the database, charged a card or sent an email. This is the core failure described in an analysis of idempotency in agentic tool calling: the model is behaving correctly given what it knows, and the missing piece is external state that would let the tool report "I already did this."

Prose makes it worse. If the error body says Something went wrong, please try again, the agent has three bad options. It can retry a permanent failure (a validation error, an expired credential) and burn the attempt budget. It can give up on a transient failure that would have succeeded on the second call. Or it can retry immediately, hammering a service that is already rate limiting it. None of those outcomes is a model reasoning problem; they are all consequences of an error body that carries no machine readable decision.

The HTTP standard already draws this line for human written clients. RFC 9110 says a client can repeat an idempotent request after a connection failure, should not retry a non-idempotent request on its own, and that a proxy must not retry a non-idempotent request at all, as summarized in this guide to idempotent REST APIs. Agents need the same rule, but they need it per response rather than per method, because a single tool endpoint often mixes safe reads with side effecting writes.

The insight that first search results tend to miss: the agent is not the right place to decide retry safety. The tool is. The tool knows whether the failure was a lock timeout or a malformed argument. Put that knowledge in the response and the agent becomes a simple state machine.

The four field error contract

A small, stable error body is enough. Four fields, all required when the response is an error:

{
  "code": "PAYMENT_LOCK_TIMEOUT",
  "retryable": true,
  "retry_after_ms": 1200,
  "stop_reason": null
}
FieldTypeMeaningWhat the agent does with it
codestringStable, documented identifier for this failure classLogs it, routes metrics on it, never parses the message text
retryablebooleanWhether repeating the same call with the same idempotency key can succeedRetries only when true
retry_after_msinteger or nullEarliest safe wait before the next attempt, in millisecondsWaits at least this long before retrying
stop_reasonstring or nullSet when retrying cannot help and a human or caller must actStops the loop and escalates with this value

Three rules make the contract work in practice.

First, code is a closed set. Treat it like an enum in the tool schema, so a new failure class requires a schema change and review. Free text codes drift, and a metric built on them becomes useless within a quarter.

Second, retryable is the tool's opinion, not the agent's inference. A rate limit response is retryable. A 400 from a schema validator is not. The tool maps its internal exceptions onto this boolean once, in one place, instead of asking every prompt to remember which status codes are safe.

Third, when stop_reason is set, retryable should be false. The two fields are not independent knobs. A permanent failure that needs a human is exactly the case where the agent must stop, and the stop reason tells the harness what to put in front of the operator: AUTH_EXPIRED, QUOTA_EXHAUSTED, SCHEMA_MISMATCH, DOWNSTREAM_REJECTED.

The response is the contract. Anything else in the body, including a human readable message, is for logs and dashboards, not for the retry decision.

What happens inside each retry attempt

The agent's retry loop is small once the contract is in place. It looks like this:

key = new_uuid()                    # generated once, before the first call
attempt = 0
budget = 4

while attempt < budget:
    resp = call_tool(args, idempotency_key=key)
    if resp.ok: return resp
    if resp.stop_reason: escalate(resp)
    if not resp.retryable: fail(resp)

    base = resp.retry_after_ms or 0
    jitter = random() * base * 0.2
    sleep(max(base + jitter, 0))
    attempt += 1

escalate(BUDGET_EXHAUSTED)

The idempotency key is generated before the first attempt and never regenerated, which is the same rule payment APIs use. The server stores the key alongside its result, so a retry with the same key returns the original outcome instead of executing the side effect twice, as the Stripe idempotent requests documentation describes for its own API. A detailed walkthrough of idempotency keys in REST APIs notes that keys are typically stored with their response for 24 hours to 7 days, which sets a hard ceiling on how long an agent can safely retry a given operation.

There is one subtle trap with the same key. Some servers reject a repeated key if the request body differs, because the key is meant to identify one intent. If the agent regenerates its arguments between attempts, for a slightly different date or amount, the second call may come back with a conflict rather than a deduplicated result. Keep the arguments byte identical across attempts of the same key, or generate a new key and accept that the operation may run twice.

The second trap is the wait itself. retry_after_ms is a floor, not a target. Always add jitter, and always sleep at least the stated value. Amazon's writeup on timeouts, retries and backoff with jitter makes the operator's side of this point: retries without jitter amplify a small outage into a full one, because every client that failed at once retries at once. The tool's returned wait replaces the agent's own backoff schedule, so the agent does not need to know whether the base is 200 ms or 2000 ms; the tool tells it, per failure.

A worked example with numbers. Suppose an agent calls a payment tool that sits behind a shared connection pool. The first attempt returns {code: "POOL_SATURATED", retryable: true, retry_after_ms: 1200, stop_reason: null}. The agent sleeps 1200 ms plus up to 240 ms of jitter, then retries with the same key. The second attempt returns retry_after_ms: 2400, so it sleeps 2400 to 2880 ms. The third attempt succeeds. Total added latency is roughly 4 to 4.6 seconds, well inside a 30 second tool timeout, and the server saw three requests instead of the six an immediate-retry loop would have generated in the same window.

Designing stop reasons that actually stop the loop

Most retry bugs are not failed retries. They are retries that never end. A tool that keeps returning retryable errors inside its time budget becomes an infinite loop with extra steps, and the agent has no reason to stop because every individual response said "try again."

The attempt budget is the primary stop condition, and it belongs in the harness, not the prompt. Set it per tool class, not per agent. A read-only lookup can tolerate a larger budget than a payment authorization. The tool's own retry_after_ms should be treated as a hint that reduces remaining budget faster when it is large: if the earliest safe wait is longer than the remaining time before the agent must return, the correct action is to stop now rather than wait and fail later.

The stop_reason handles the other case, where waiting is pointless no matter how many attempts remain. Four values cover most tools:

stop_reasonTypical causeWho acts next
AUTH_EXPIREDCredential or token rejectedHuman refreshes the secret
QUOTA_EXHAUSTEDAccount or tenant limit reachedOwner buys capacity or waits for reset
SCHEMA_MISMATCHArguments failed validationModel or prompt must change the call
DOWNSTREAM_REJECTEDA third party declined the operationBusiness process decides

The pattern to avoid is stuffing this information into code alone. A code tells you what happened. A stop reason tells the agent and the operator what to do about it. Keeping them separate means a dashboard can count failures by cause while the runtime follows a small, stable policy.

Cockroach Labs' walkthrough of what write skew looks like is a useful parallel for the operator experience. The interesting question in a concurrency bug is not "what state did we land in" but "which interleaving produced it." Retry metadata plays the same role: the agent's trace should show the key, the attempt number, the code, the wait and the stop reason, so a failed payment can be reconstructed after the fact.

Idempotency keys that survive retries and restarts

The key must be derived from durable state, or it must be persisted before the first attempt. Generating a fresh UUID at the top of a retry loop is the single most common bug in agent retry code, because it converts every retry into a new operation. A collection of patterns for making agent retries idempotent puts this first on the list for exactly that reason.

For single tool calls, a UUID generated once before the first attempt and written into the step's durable record is enough. For multi step workflows, derive the key from durable identifiers so that a resumed run regenerates the same key. The approach described in the saga and deduplication analysis uses {workflowRunId}:{stepId} and argues the key should be deterministic rather than random, so that a crash and resume produce the same key without needing to have stored it.

The server side has to cooperate. An implementation guide for idempotent REST APIs in ASP.NET Core describes the standard shape: the client sends a unique key in a header, the server checks whether it has seen that key before, and if so returns the stored result without reprocessing. The same source quotes RFC 9110's reminder that a server is free to log each request separately, so a deduplicated retry can still show up in your access logs. That is fine. What must not happen is a second charge or a second ticket.

There is a real cost to be honest about. Every tool that accepts an idempotency key needs storage for the key and its result, plus an expiry policy. The DZone piece on idempotency frames the problem through the phantom timeout: the request arrived, the side effect happened, and the response never came back. The agent genuinely cannot tell whether it needs to retry. A stored result turns that ambiguity into a cheap lookup.

For a tool that writes to a database, the deduplication can ride on a unique constraint rather than a separate store. Insert the idempotency key into a column with a unique index, and a duplicate insert fails fast with a code the tool maps to retryable: false and the original result. That is less code than a cache and it survives process restarts for free.

Interpreting waits when the database is the tool

Agents often call tools that are thin wrappers over a database, and the wait semantics change at that layer. A connection pool is the clearest case. PgBouncer queues client connection requests once a pool reaches its configured size, and only aborts a connection when the client itself exceeds its own limit, as the ScaleGrid overview of PgBouncer explains. That means a saturated pool produces queueing, not an error, and the right retry_after_ms depends on how deep the queue is.

Two things follow for the contract. First, a tool that wraps a database should set retryable: true and a conservative retry_after_ms on pool saturation, and it should not treat a queued connection as a hard failure. Second, the tool's retry semantics depend on the pool mode. Heroku's PgBouncer configuration guide recommends transaction pooling for most applications, and the PgBouncer feature matrix shows why: transaction pooling breaks session state such as SET, LISTEN and WITH HOLD cursors, so a tool that relied on session state in the first attempt may fail on a retry that lands on a different server connection. The failure will look transient to the agent. It is not. Map it to retryable: false with a stop reason, and fix the tool to avoid session state.

The same logic applies to transactions inside the tool. If the tool's unit of work is a transaction, a serialization failure is retryable and the tool should say so with a short wait. A constraint violation is not. Both can surface as the same driver exception class, which is exactly why the mapping has to live in the tool and not in the model's guesswork.

For observability, the query plan is the equivalent of the retry trace. Datadog's guide to EXPLAIN ANALYZE makes the point that actual row counts and actual time per node are what reveal why a query is slow, rather than the plan's estimates. A retry log is the same instrument: per attempt, record the wait actually taken, the code returned, and whether the call reached the server. An agent that waited 1200 ms and still lost is a different bug from an agent that never waited at all.

Testing the contract against the real tool

A contract is only as good as its tests. Five test cases cover most of the risk, and each one should run against a real instance of the tool, not a mock that returns the body you wish it would return.

TestSetupAssertion
Transient retryForce a lock timeout or a 503Agent retries, waits at least retry_after_ms, reuses the key, succeeds
Rate limitExceed the tool's quotaAgent honors the wait, does not retry early, backs off across attempts
Permanent failureSend an invalid argumentAgent does not retry, stops immediately
Budget exhaustionKeep the tool returning retryable errorsAgent stops at the budget and escalates with a stop reason
Duplicate deliveryReplay the same key twiceThe tool performs the side effect once and returns the same result

The duplicate delivery test is the one teams skip, and it is the one that catches a server that accepts the key but never checks it. Replay the same request with the same key and assert that the database has one row, not two. A worked example in the agentic saga article notes that a payment call can return 200 before the confirmation is stored, so the test should assert the final state, not just the status code.

Test with real failures rather than simulated ones. A test harness that returns arbitrary status codes validates the agent's loop, but it does not validate the tool's mapping, so pair it with at least one test against the actual tool under a real, forced failure.

Finally, assert on timing, not just on the final result. A test that passes because the agent eventually succeeded will hide an agent that retried in a tight loop for two seconds before the service happened to recover. Capture the timestamps of each attempt and assert that the gap is at least the value the tool returned.

If your agent calls several kinds of tool (text, image, audio, embeddings), running the same tests across providers is the tedious part. Routing them through Quanta Inference gives one OpenAI-compatible surface and one key, which keeps the test harness small and the retry policy uniform across model types.

FAQ

FAQ

Should the retry policy live in the tool or in the agent?

Both, with different roles. The tool decides per failure whether retrying is safe and how long to wait. The agent owns the attempt budget, the key generation and the escalation path. Mixing the two, for example by letting the agent infer retryability from a status code, is how you get the double charge pattern described in the agentic idempotency analysis.

What happens if the tool returns retryable true but keeps failing?

The attempt budget ends the loop. Set it per tool class, count every attempt including the first, and treat a large retry_after_ms as a reason to stop early if the wait would exceed the remaining time. The agent then escalates with a stop reason of BUDGET_EXHAUSTED, and the trace shows each wait and code.

Can I just use the HTTP Retry-After header instead of retry_after_ms?

You can map it, but do not expose the raw header to the model. Retry-After can be a date or a number of seconds and applies to a small set of status codes. The tool should convert it into a single integer of milliseconds so the agent has one type and one unit to reason about, and so the contract stays identical for non-HTTP tools.

Do I need a new idempotency key if the agent's arguments change between attempts?

Yes. A stored key is tied to one intent, and servers reject a reused key with different arguments, which the idempotency key guide notes is a deliberate protection. If the model needs to change the arguments, that is a new operation and a new key, and the agent should treat the earlier attempt as possibly having executed.

How do I keep retry keys from expiring mid-operation?

Choose a key storage window longer than the worst case retry budget. The Stripe documentation describes a default retention window for its own API, and the Zuplo guide puts typical windows at 24 hours to 7 days. If your agent's budget is minutes, day scale retention is ample. The risk only appears when a workflow resumes after a long pause and reuses an old key, so tie key lifetime to the workflow run, not to the tool.

Sources

Sources

  1. The Idempotency Problem in Agentic Tool Calling
  2. Idempotency - What is an Idempotent REST API?
  3. Idempotent requests | Stripe API Reference
  4. Implementing Idempotency Keys in REST APIs
  5. Timeouts, retries, and backoff with jitter
  6. What write skew looks like
  7. 7 patterns that make agent retries idempotent, not duplicative
  8. Implementing Idempotent REST APIs in ASP.NET Core
  9. Idempotency: Preventing Double Charges and Duplicate Actions
  10. PostgreSQL Connection Pooling: Part 2, PgBouncer - ScaleGrid
  11. PgBouncer Configuration and Best Practices - Heroku Dev Center
  12. PgBouncer features
  13. Debug PostgreSQL query latency faster with EXPLAIN ANALYZE in ...
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