Tool Retry & Backoff
Automatically retry failing tool calls with exponential backoff and jitter
ExecutionConfig retry settings re-run retryable tool failures and guardrail validation errors with exponential backoff and jitter (for tools that have not declared themselves unsafe to re-run — see Retrying Non-Idempotent Tools). For tools, max_retry_limit / retry_* are translated into the effective RetryPolicy — the single tool-retry budget shared with ToolConfig(retry_policy=...).
from praisonaiagents import Agent, tool
@tool
def fetch_report(query: str) -> str:
"""Fetch a report from a flaky API."""
return f"Report for: {query}"
agent = Agent(name="Resilient Agent", tools=[fetch_report], max_retry_limit=3)
agent.start("Get me the latest report")The user requests a report; transient tool failures retry with exponential backoff and jitter until success or the limit is reached.
Quick Start
- Enable with defaults
Retries with backoff are automatic when you set
max_retry_limit:python from praisonaiagents import Agent, tool @tool def fetch_report(query: str) -> str: """Fetch a report from a flaky API.""" return f"Report for: {query}" agent = Agent( name="Resilient Agent", instructions="Fetch data from flaky APIs", tools=[fetch_report], max_retry_limit=3, ) agent.start("Get me the latest report") - Fine-tune backoff
Use
ExecutionConfigfor delay, factor, and jitter:python from praisonaiagents import Agent, ExecutionConfig, tool @tool def fetch_report(query: str) -> str: """Fetch a report from a flaky API.""" return f"Report for: {query}" agent = Agent( name="Resilient Agent", instructions="Fetch data from flaky APIs", tools=[fetch_report], execution=ExecutionConfig( max_retry_limit=3, retry_initial_delay=0.5, retry_backoff_factor=2.0, retry_jitter=0.2, ), )
How It Works
Total attempts = 1 + max_retry_limit. Default max_retry_limit=2 → up to 3 attempts.
Delay: min( min(initial_delay × factor^(attempt−1), 60s) + random(0, jitter × base), 60s ). As of PR #5050, the cap is re-applied after jitter, so max_delay is a hard ceiling for both the base delay and the jittered result — no attempt ever waits longer than the cap.
The translation maps max_retry_limit → max_attempts (= limit + 1), retry_initial_delay (seconds) → initial_delay_ms, retry_backoff_factor → backoff_factor, and retry_jitter (fraction) → jitter=True + jitter_factor. The translated policy sets max_delay_ms = max(60000, initial_delay_ms), so ExecutionConfig users keep the historical 60-second delay cap (a plain RetryPolicy defaults to a 30-second cap).
Choosing Your Settings
Configuration
| Field | Type | Default | Description |
|---|---|---|---|
max_retry_limit | int | 2 | Max retries after the first attempt |
retry_initial_delay | float | 1.0 | First retry delay (seconds) |
retry_backoff_factor | float | 2.0 | Exponential multiplier per attempt |
retry_jitter | float | 0.1 | Jitter fraction of base delay |
What Gets Retried
| Outcome | Retried? |
|---|---|
Tool declares @tool(restart_safe=False) / .idempotent = False — any retryable error (sync and async) | ❌ (runs exactly once — PR #4299 restored async parity) |
Wall-clock (outer) timeout — mutating tool (in MUTATING_TOOLS) or unknown tool | ❌ (surfaced once — see Tool Timeouts) |
| Wall-clock (outer) timeout — idempotent tool | ✅ |
| Circuit breaker open | ✅ |
| Unexpected exceptions (not a caller error listed below) | ✅ |
| Guardrail validation failures | ✅ |
Tool returns {"error": ...} without timeout / circuit_open | ❌ |
Caller errors (ValueError, TypeError, AttributeError, LookupError (incl. KeyError / IndexError), FileNotFoundError, FileExistsError, IsADirectoryError, NotADirectoryError, PermissionError) | ❌ |
Retrying Non-Idempotent Tools
A tool that declares itself unsafe to re-run is executed exactly once, even when the error would otherwise be retryable — the body already ran and may have completed its side effect before raising, so retrying would duplicate it.
Declare it on the decorator and the retry budget is ignored for that tool:
from praisonaiagents import Agent, tool
@tool(restart_safe=False)
def charge_card(amount: str) -> str:
"""Charge a customer's card — must never be re-driven."""
return billing.charge(amount)
agent = Agent(
name="Billing Agent",
instructions="Charge the customer for the pending invoice.",
tools=[charge_card],
max_retry_limit=3, # ignored for charge_card — it runs once
)
agent.start("Charge invoice #A-1042 for $99")The declaration is discovered in this order (first match wins):
tool.idempotentset to aboolon the tool object.tool.restart_safe— the public@tool(restart_safe=...)/BaseTool.restart_safecontract.- When
ToolConfig(allow_global_tools=True)is set, a matching global-registry tool’sidempotent/restart_safeattribute (parity with idempotency lookup).
Common Patterns
Disable retries:
agent = Agent(name="strict", max_retry_limit=0)Rate-limited APIs:
execution=ExecutionConfig(
max_retry_limit=4,
retry_initial_delay=2.0,
retry_backoff_factor=3.0,
retry_jitter=0.3,
)Low-latency tools:
execution=ExecutionConfig(
max_retry_limit=2,
retry_initial_delay=0.1,
retry_backoff_factor=1.5,
)Best Practices
Set jitter for parallel agents
Jitter spreads retry timing across agents and reduces thundering-herd spikes on shared APIs.
Don't retry programming errors
Caller errors — ValueError, TypeError, AttributeError, LookupError (incl. KeyError / IndexError), FileNotFoundError, FileExistsError, IsADirectoryError, NotADirectoryError, PermissionError — are treated as bad input or code bugs and are not retried. A bare OSError or ConnectionError is still retried.
Mark tools with side effects as non-idempotent
A tool that sends, writes, charges, or deletes should declare @tool(restart_safe=False) (or set .idempotent = False). The tool body runs before it can raise, so retrying after a transient error would duplicate the side effect. Declared-unsafe tools run exactly once regardless of max_retry_limit.
Backoff is capped at 60s (hard ceiling, including jitter)
The 60-second cap applies after jitter is added, so no attempt ever waits longer than max_delay. Very large backoff factors cannot exceed the cap. The cap only matters when retries actually happen — declared-unsafe tools do not retry.
Guardrails share these settings
Guardrail validation retries use the same ExecutionConfig backoff values. This is independent of tool retry — the fields still feed guardrail-retry backoff even when a ToolConfig(retry_policy=...) overrides the tool budget.
One budget, not two
For tools, ExecutionConfig.retry_* is an alias translated into the effective RetryPolicy — the same budget as ToolConfig(retry_policy=...). Setting an explicit retry_policy overrides the ExecutionConfig spelling.