Agent Retry
Automatically retry LLM API calls with jittered exponential backoff
Agent retry automatically re-runs failed LLM calls — across single-shot, streaming, tool-iteration and reflection turns — with jittered exponential backoff so transient rate limits, overloads, and network blips don’t break your agent. Retry is on by default (max_retries=3) on both the native OpenAI-client path and the LiteLLM path. Pass retry=False to opt out.
from praisonaiagents import Agent
# Retry is on by default — no retry= needed.
agent = Agent(
name="Researcher",
instructions="Research topics on the web",
)
agent.start("Find recent papers on diffusion models")The user runs an agent; transient LLM failures retry automatically with jittered backoff — no retry= argument needed.
Quick Start
- Default — retries are already on
python from praisonaiagents import Agent # No retry= — retry is on by default with sensible settings. agent = Agent( name="Researcher", instructions="Research topics on the web", ) agent.start("Find recent papers on diffusion models")An
Agent()with noretry=argument retries transient LLM failures automatically — on both the native and LiteLLM paths.The default policy: 3 retries, 5 s → 10 s → 20 s exponential schedule, capped at 120 s, with 50% additive jitter.
retry=Trueis equivalent and explicit. - Disable with retry=False
python from praisonaiagents import Agent agent = Agent( name="Fast Checker", instructions="Quick health check", retry=False, ) agent.start("Ping the model once")retry=Falseturns retries off — the only way to opt out now that retries are on by default. Use it for strict fail-fast pipelines or local LLM servers (LM Studio / vLLM) that never emitRetry-After. - Tune with a dict (no extra import)
python from praisonaiagents import Agent agent = Agent( name="Researcher", instructions="Research topics on the web", retry={ "max_retries": 5, "base_delay": 2.0, "max_delay": 60.0, "jitter_ratio": 0.3, }, ) agent.start("Find recent papers on diffusion models") - Full control with RetryBackoffConfig
python from praisonaiagents import Agent, RetryBackoffConfig agent = Agent( name="Researcher", instructions="Research topics on the web", retry=RetryBackoffConfig( base_delay=2.0, max_delay=60.0, jitter_ratio=0.3, max_retries=5, ), ) agent.start("Find recent papers on diffusion models")
How It Works
| Aspect | Detail |
|---|---|
| What gets retried | Only LLMError where is_retryable=True (rate limits, overloads) |
| What does NOT get retried | Auth errors, invalid requests, non-retryable LLMError, any other exception |
| Total attempts | max_retries + 1 (default: 4 total) |
| Backoff schedule | min(base_delay × 2^attempt, max_delay) + uniform(0, jitter_ratio × delay) |
| Interruption | Raises RuntimeError("Agent interrupted during retry backoff") immediately |
Default when retry= is unset | Applies RetryBackoffConfig() (max_retries=3, base_delay=5.0, max_delay=120.0, jitter_ratio=0.5) — same as the LiteLLM path |
Which turn shapes retry?
retry= applies to every LLM call the agent makes, whether the model is being called for the first turn, a streaming turn, a tool-iteration turn, a reflection turn, or an async equivalent. If you can configure it, it retries.
| Turn shape | Retried? |
|---|---|
Non-streaming single-shot (agent.start(...)) | ✅ |
Streaming (agent.start(..., stream=True)) | ✅ |
| Tool-iteration turns inside a multi-tool loop | ✅ |
Reflection turns (when self_reflect=True) | ✅ |
Async equivalents (agent.astart(...)) | ✅ |
| Reasoning-steps single-shot | ✅ |
Custom LLMs (any provider/model string, dict LLM config, or base_url/api_key construction) | ✅ — honoured. retry=False → max_retries=0. An explicit max_retries inside a dict LLM config wins. |
max_retries also caps the recursive retries the LLM path performs after context compression and after backing off on a transient error. These were previously fixed at 2; they now follow the configured policy, and fall back to 2 only when retry is disabled (retry=False). Since retry is on by default, the recursive bound follows max_retries=3 unless you override it.
Configuration Options
Omitting retry= is the same as retry=True — the default RetryBackoffConfig() applies.
RetryBackoffConfig Fields
| Option | Type | Default | Description |
|---|---|---|---|
base_delay | float | 5.0 | Base delay in seconds for the first retry. |
max_delay | float | 120.0 | Upper cap on any single backoff (after jitter). |
jitter_ratio | float | 0.5 | Adds uniform(0, jitter_ratio × delay) on top of exponential delay. Set 0.0 to disable jitter. |
max_retries | int | 3 | Maximum number of retries. Bounds both the outer LLMError-throwing retry loop (up to 4 total attempts by default) and the recursive _chat_completion retries after context compression / transient backoff. When retry is disabled (retry=False), the internal loop caps at 2 for backwards compatibility. |
Validation — the constructor raises ValueError if:
base_delay <= 0max_delay < base_delayjitter_ratiooutside[0, 1]max_retries < 0
The TypeScript SDK ships the same RetryBackoffConfig with byte-for-byte identical validation messages, so error handling ports across languages unchanged:
TypeScript
import { RetryBackoffConfig } from 'praisonai';
const cfg = new RetryBackoffConfig({ baseDelay: 2.0, maxDelay: 60.0, jitterRatio: 0.25, maxRetries: 5 });
console.log(cfg.delayFor(1)); // seconds for the 2nd attempt
// Invalid config throws a RangeError with the same message text as Python:
// 'base_delay must be > 0'
// 'max_delay must be >= base_delay'
// 'jitter_ratio must be between 0 and 1'
new RetryBackoffConfig({ baseDelay: 0 }); // RangeError: base_delay must be > 0Precedence & Defaults
| You pass | What happens |
|---|---|
Nothing (omit retry=) or retry=None | RetryBackoffConfig() with max_retries=3 — retries transient errors on both native and LiteLLM paths |
retry=False | Off — no retry loop on either path (fast fail) |
retry=True | Same as default (RetryBackoffConfig(), max_retries=3) |
retry=RetryBackoffConfig(...) | Fully custom |
retry={"max_retries": N, ...} (dict) | Converted to RetryBackoffConfig(**dict) |
retry= on a custom LLM (llm="provider/model", dict config, or base_url) | Same as the native path — retry=False → max_retries=0, retry=RetryBackoffConfig(max_retries=N)/retry={"max_retries": N} → max_retries=N (since PR #3769) |
# Default — retry ON with sensible defaults (max_retries=3)
agent = Agent(name="A", instructions="...")
# retry=True — same as default
agent = Agent(name="A", instructions="...", retry=True)
# retry=False — OFF, fast fail on both paths
agent = Agent(name="A", instructions="...", retry=False)
# Dict — constructed from field names
agent = Agent(name="A", instructions="...", retry={"max_retries": 5, "base_delay": 2.0})
# RetryBackoffConfig — used directly
from praisonaiagents import Agent, RetryBackoffConfig
agent = Agent(
name="A",
instructions="...",
retry=RetryBackoffConfig(max_retries=5, base_delay=2.0),
)Custom LLM support
retry= now applies to every LLM configuration path:
Agent(llm="openai/gpt-4o", retry=...)— provider/model stringAgent(llm={"model": ..., "base_url": ..., ...}, retry=...)— dict configAgent(base_url=..., retry=...)— custom endpoint
Before PraisonAI PR #3769, retry= was silently ignored on these paths and the underlying LLM fell back to its hardcoded max_retries=3. After the fix, retry=False correctly disables retries, and retry=RetryBackoffConfig(max_retries=N) / retry={"max_retries": N} are forwarded to the custom LLM.
One precedence rule to know: if you set max_retries inside a dict llm= config, that wins over retry=. This mirrors how every other field in the llm= dict is authoritative.
from praisonaiagents import Agent
# dict-config max_retries wins over retry=
agent = Agent(
name="A",
instructions="...",
llm={"model": "openai/gpt-4o", "max_retries": 5},
retry={"max_retries": 10}, # ignored — dict wins
)
# → custom LLM runs with max_retries=5| Config | Result |
|---|---|
retry=False on custom LLM | Custom LLM initialised with max_retries=0 |
retry=RetryBackoffConfig(max_retries=N) on custom LLM | Custom LLM initialised with max_retries=N |
retry={"max_retries": N} on custom LLM | Custom LLM initialised with max_retries=N |
retry= unset on custom LLM | LLM class’s own default (currently max_retries=3) — unchanged |
Dict config with explicit max_retries plus retry= | Dict config max_retries wins — user-set dict values are never overridden |
Dict config without max_retries plus retry= | retry-derived max_retries is forwarded into the dict |
Common Patterns
Rate-limit friendly long jobs
from praisonaiagents import Agent, RetryBackoffConfig
agent = Agent(
name="Batch Processor",
instructions="Process a large batch of documents",
retry=RetryBackoffConfig(
base_delay=1.0,
max_delay=300.0,
jitter_ratio=0.5,
max_retries=10,
),
)
agent.start("Summarise all 500 documents in the queue")Strict mode — fail fast
Pass retry=False to disable retries entirely and surface transient errors immediately.
from praisonaiagents import Agent
agent = Agent(
name="Fast Checker",
instructions="Quick health check",
retry=False,
)For a small-but-nonzero budget, keep retries on with a low max_retries:
from praisonaiagents import Agent, RetryBackoffConfig
agent = Agent(
name="Fast Checker",
instructions="Quick health check",
retry=RetryBackoffConfig(max_retries=1),
)Disable recursive retries entirely
Set max_retries=0 to disable the recursive compression / transient-backoff retries while keeping the retry policy configured.
from praisonaiagents import Agent, RetryBackoffConfig
agent = Agent(
name="No Recursion",
instructions="Surface transient failures immediately",
retry=RetryBackoffConfig(max_retries=0),
)Reproducible tests — disable jitter
from praisonaiagents import Agent, RetryBackoffConfig
agent = Agent(
name="Test Agent",
instructions="Deterministic for testing",
retry=RetryBackoffConfig(jitter_ratio=0.0),
)Observe retries with a hook
from praisonaiagents import Agent, RetryBackoffConfig
from praisonaiagents.hooks import HookRegistry, HookEvent
registry = HookRegistry()
@registry.on(HookEvent.ON_RETRY)
def on_retry(event):
print(
f"[retry] attempt {event.attempt + 1}/{event.max_retries} "
f"in {event.delay_seconds:.1f}s: {event.error_message[:80]}"
)
agent = Agent(
name="API Caller",
instructions="Call the API",
retry=RetryBackoffConfig(max_retries=5),
hooks=registry,
)
agent.start("Fetch the latest data")The OnRetry hook receives:
| Field | Type | Description |
|---|---|---|
attempt | int | Current attempt number (0-based) |
max_retries | int | Configured max retries |
delay_seconds | float | Seconds the agent will sleep before the next attempt |
error_message | str | String representation of the failing LLMError |
operation | str | "llm_request" (sync) or "async_llm_request" (async) |
Observability
The same backoff moment surfaces through two consumers — pick whichever fits your integration:
| Consumer | Best for | Payload |
|---|---|---|
ON_RETRY hook | Programmatic control, metrics, alerting | attempt, max_retries, delay_seconds, error_message, operation |
RETRY stream event | Live UIs and stream-json pipelines | event.metadata: attempt, max_attempts, delay, reason |
Same signal, two consumers — hooks for programmatic control, stream events for live UIs and stream-json pipelines. When a run uses praisonai run --output stream-json, the stream event is emitted as a canonical run.retry NDJSON event, so a rate-limited run shows retrying in Ns (attempt k/N) instead of a silent-looking terminal.
from praisonaiagents import Agent
from praisonaiagents.streaming import StreamEvent, StreamEventType
def on_event(event: StreamEvent):
if event.type == StreamEventType.RETRY:
m = event.metadata or {}
print(f"Retrying in {m['delay']:.1f}s (attempt {m['attempt']}/{m['max_attempts']}) — {m['reason']}")
agent = Agent(name="API Caller", instructions="Call the API")
agent.stream_emitter.add_callback(on_event)
agent.start("Fetch the latest data")Best Practices
Keep the defaults, tune only when needed
The defaults (base_delay=5.0, max_delay=120.0, jitter_ratio=0.5, max_retries=3) ship on every Agent and suit most OpenAI and Anthropic rate-limit patterns. Leave retry= off and only tune when you observe systematic timeouts or excessive waiting.
Opt out with retry=False for strict fail-fast paths
If your pipeline needs to surface transient LLM errors immediately — a health check, a local LM Studio / vLLM endpoint that never emits Retry-After, or a batch job with a hard wall-clock budget — pass retry=False explicitly. Omitting retry= no longer disables retries.
Don't disable jitter in production
Setting jitter_ratio=0.0 creates a deterministic schedule that is useful for tests but dangerous in production. When many agents share the same API key and all retry at the same second, they hammer the endpoint simultaneously — exactly what jitter prevents. Keep jitter_ratio at 0.3 or higher in production.
Cap max_delay for user-facing flows
A 120-second wait is acceptable for background batch jobs but not when a human is waiting for a response. For interactive agents, set max_delay to something like 20.0 or 30.0, and keep max_retries low (1–2).
Use the OnRetry hook for observability, not control flow
The OnRetry hook is the right place to log metrics and send alerts. Retries are best-effort — if all attempts fail, the original LLMError propagates to your caller. Build your resilience strategy around catching that exception in your application code, not inside the hook.
In AG-UI
Over the AG-UI wire, a RETRY reaches the client as a custom event named praisonai.retry with value { message, metadata }. A MODEL_FALLBACK arrives as praisonai.model_fallback. See the AGUI API reference.
Related
SDK-level Retry-After + backoff for the native OpenAI client path.
Retry tool calls — a different surface from LLM call retry.
Structured LLM ErrorsWhich LLMError categories are classified as retryable.
The OnRetry event and all other lifecycle hooks.
Strategy guidance for production retry patterns.