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=...).

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", 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.

Diagram

Fail

Success

📝 Request

🔧 Tool

⏱️ Backoff

🔄 Retry

✅ Done

Quick Start

  1. 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")
  2. Fine-tune backoff

    Use ExecutionConfig for 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

Diagram
ToolAgentToolAgentdelay = min(min(1.0 × 2^0, 60s) + jitter, 60s)alt[Non-retryable error][Retryable + under limit]Attempt 1Retryable errorAttempt 2Return error as-isSuccess or retry again

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

Diagram

Rate-limited API

Flaky network

Deterministic tools

Use case?

Higher initial_delay + jitter

Higher max_retry_limit

max_retry_limit=0


Configuration

FieldTypeDefaultDescription
max_retry_limitint2Max retries after the first attempt
retry_initial_delayfloat1.0First retry delay (seconds)
retry_backoff_factorfloat2.0Exponential multiplier per attempt
retry_jitterfloat0.1Jitter fraction of base delay

What Gets Retried

OutcomeRetried?
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.

Diagram

Undeclared

restart_safe=True / idempotent=True

restart_safe=False / idempotent=False

🔧 Tool fails with a retryable error

Declared unsafe?

🔄 Retries per policy

🛑 Runs once — failure returned as-is

Declare it on the decorator and the retry budget is ignored for that tool:

python
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.idempotent set to a bool on the tool object.
  • tool.restart_safe — the public @tool(restart_safe=...) / BaseTool.restart_safe contract.
  • When ToolConfig(allow_global_tools=True) is set, a matching global-registry tool’s idempotent / restart_safe attribute (parity with idempotency lookup).

Common Patterns

Disable retries:

python
agent = Agent(name="strict", max_retry_limit=0)

Rate-limited APIs:

python
execution=ExecutionConfig(
    max_retry_limit=4,
    retry_initial_delay=2.0,
    retry_backoff_factor=3.0,
    retry_jitter=0.3,
)

Low-latency tools:

python
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.



Related