Skip to main content
Tool retry automatically re-runs a failing tool with exponential backoff so transient errors don’t break your agent.
The user asks for web research; RetryPolicy re-runs retryable tool errors with backoff before surfacing a failure.

Quick Start

1

Enable with defaults

Enable retry for all tools with safe defaults:
2

Tune attempts and backoff

Configure specific retry behavior:
3

Override per tool

Different tools may need different retry strategies:

How It Works

An error is tagged rate_limit when its message contains either "rate…limit" or "too many requests" — the latter catches HTTP 429 responses whose bodies use the standard phrasing without the word “limit”:

Sync / async parity

RetryPolicy applies identically on the sync path (agent.execute_tool(...), agent.start(...)) and the async path (agent.execute_tool_async(...), agent.achat(...)) — including tools that raise exceptions.
Fixed in PR #4141. Before that release, agent.achat(...) would silently not retry a raised exception, even when ToolConfig(retry_policy=RetryPolicy(max_attempts=N)) was set. The sync path already retried; the async path returned on the first attempt.
Parity also fixed for the restart_safe=False veto in PR #4299 (issue #4298). PR #4141 restored async retry for raised exceptions; PR #4299 restored async veto for tools that declared themselves unsafe. Before #4299, agent.achat() / agent.execute_tool_async() re-ran a @tool(restart_safe=False) body up to max_attempts times even though the sync path already honoured the declaration.
On the sync path a tool that exhausts its per-tool retries raises ToolExecutionError, which propagates directly out of agent.chat() / agent.start() (fix in PR #4252). It is not routed through the LLM error classifier — so a tool message that looks like a rate limit (e.g. "429 too many requests") does not trigger an LLM backoff time.sleep or a redundant model call. The per-tool retry wrapper (max_attempts) is what governs tool-error retries; retry / RetryBackoffConfig are LLM-error-driven and never fire on a tool failure. See Error Handling → Tool Failure Behavior.
The veto is now honoured on both paths too (PR #4299) — a declared-unsafe tool runs exactly once whether called sync or async:
Both paths share the same terminal rule for raised exceptions: On the async path a raised exception is flattened into an error dict carrying the private _praison_retryable verdict, then the retry loop honours it and strips it before returning:

Your tool’s own retryable field is safe

Many HTTP-API wrappers return payloads shaped like {"error": "...", "retryable": true}. That field is the tool’s payload and is preserved untouched on the surfaced result. The framework’s own verdict lives on the private _praison_retryable key, which is stripped before the result reaches the model or the caller — the two never collide. To make a returned error retry, tag its error type via RetryPolicy(retry_on=...). A word in the tool’s payload is not how retry is controlled.
The same holds on the async path — the body still runs exactly once:

Returned error dicts run once

A tool that returns an error dict has already reached its own decision — the body ran to completion. Re-running it would duplicate side effects (charge money, send a message, write a row) for a decision the tool already made. The sync outer loop therefore surfaces such a dict exactly once, regardless of what fields it contains. To make a returned error retry-eligible, register its error type via RetryPolicy(retry_on={...}) — the inner loop honours that classification, and the outer loop will not double-drive it.

Outer wall-clock timeouts retry only for idempotent tools

The outer loop’s own wall-clock timeout (_outer_timeout) is now gated on the tool’s idempotency: a timed-out tool is re-driven only if it is classified as idempotent. The timed-out body is still running in an orphaned worker thread (Future.cancel() is a no-op once started), so auto-retrying a mutating tool would duplicate its side effects. Idempotency comes from an explicit idempotent attribute on the tool, the global registry (with ToolConfig(allow_global_tools=True)), or the shared IDEMPOTENT_TOOLS / MUTATING_TOOLS name registry.
MUTATING_TOOLS gates timeout-retry decisions here; it does not veto ordinary tool-error retries. See Tool Retry & Backoff → Retrying Non-Idempotent Tools for how the veto works on the ordinary retry path.
Unknown tools default to non-idempotent (safe): a wall-clock timeout on an unrecognised tool surfaces once instead of retrying. See Tool Timeouts → Retry-on-timeout is opt-in for the .idempotent = True opt-in.

RetryPolicy is the single budget

The effective RetryPolicy is the one budget for tool-body runs. RetryPolicy.max_attempts genuinely caps how many times a tool runs, and the ExecutionConfig spelling (max_retry_limit / retry_*) is translated into that same policy in one place.
Set RetryPolicy(max_attempts=1) for non-idempotent tools — a payment, an email, a POST. This now runs the tool exactly once, preventing duplicate side effects.
ExecutionConfig.retry_* and ToolConfig(retry_policy=...) are two spellings of one budget, not two separate loops. See Tool Retry & Backoff and ExecutionConfig for the ExecutionConfig spelling.
How many times a failing tool body actually runs:

Precedence Ladder

When no retry_policy is set, the fallback is the policy translated from ExecutionConfig (max_retry_limit / retry_*), which defaults to RetryPolicy(max_attempts=3) when ExecutionConfig is untouched. Users who never set retry_policy= see no behaviour change — the translated policy reproduces max_retry_limit + 1 attempts and the same backoff. retry_policy=None on @tool(...) means use the fallback — the tool-level slot is treated as empty, not as “no retries”. To disable retries for a specific tool, pass an explicit RetryPolicy(max_attempts=1) instead. Tool-level (highest priority):
Do NOT pass retry_policy=None to disable retries. @tool(retry_policy=None) is treated as “no policy set here” and falls through to the agent-level or default RetryPolicy. To actually disable retries for a specific tool, pass RetryPolicy(max_attempts=1).
Agent-level (medium priority):
Translated ExecutionConfig fallback (lowest priority):

Task-scoped tools

A tool passed to a task via tools_override (not registered on the agent) participates in the same idempotency lookup as an agent-level tool. Its restart_safe=False / idempotent=False declaration is honoured on both sync and async paths (PR #4299). Task-scoped tools shadow same-named agent tools when both are present, so a safer per-task override wins.

Choosing a Retry Policy


Configuration Options

Non-retryable error types (always short-circuit):
  • approval_denied, permission_denied, approval_error, circuit_open, loop_blocked
  • Python exceptions: ValueError, TypeError, AttributeError from tool code
  • Raised ValueError / TypeError / AttributeError from tool code are terminal on both sync and async paths.
  • Argument-binding errors (missing/extra parameters resolved from the schema) are terminal — they never retry regardless of policy.
A loop_blocked result comes from Loop Guard. Bumping max_attempts cannot bypass it — configure the loop-guard thresholds instead.

Common Patterns

Per-tool override for unreliable API

YAML configuration

In YAML the field name is still tool_retry_policy:; in Python pass retry settings through tool_config=ToolConfig(retry_policy=…). The standalone tool_retry_policy kwarg on Agent(...) was removed and raises TypeError.

CLI usage

If the retry backend isn’t available (the CLI accepted the flags but the runtime dependency is missing), you’ll now see a tool_retry_policy requested but retry backend unavailable: <ImportError> warning in the logs instead of the settings being silently dropped. Install the retry backend or drop the --tool-retry-* flags to clear the warning.

Hook Integration

Monitor retry attempts with hooks:
Available fields on OnRetryInput:
  • tool_name: Name of the failing tool
  • attempt: Current attempt number (1-based)
  • max_attempts: Maximum attempts configured
  • delay_ms: Delay before this retry in milliseconds
  • error_type: Classified error type (timeout, rate_limit, etc.)
  • error: Original exception object

Best Practices

Large retry counts mask real failures. If a tool fails 10+ times, there’s likely a deeper issue that retrying won’t solve. Use monitoring instead.
Without jitter, multiple agents retrying simultaneously create a “thundering herd” that can overwhelm rate-limited services. Jitter spreads out retry attempts.
Don’t retry LLM tools on connection_error if every attempt costs money. Use specific error types that indicate transient failures.
Agent-level retry policy keeps configuration DRY. Only override at the tool level for genuinely special cases like unreliable third-party APIs.

Tool Configuration

Consolidated tool configuration with ToolConfig

Concurrency

Parallel tool execution and timeouts

Hooks

Monitor and intercept agent behavior

Hook Events

Complete reference of hook events