Sync and Async Parity: Circuit breaker protection applies uniformly to both sync and async tool execution paths. Parity was first delivered in MervinPraison/PraisonAI#4469, which wraps each async invocation through
CircuitBreaker.acall(...) β event-loop safe, no time.sleep. MervinPraison/PraisonAI#4533 then added the _circuit_breaker_precheck(...) / _circuit_breaker_record(...) helpers for the pre-check and outcome recording. Both a raised exception inside the tool and a result dict carrying error count as breaker failures β but approval / permission / policy / guardrail denials do not. Earlier releases skipped the breaker on achat()/astart(). MervinPraison/PraisonAI#4969 then removed a double-record on the async path and started recording wait_for cancellations so async timeouts count toward the threshold like sync failures.This tool-level circuit breaker is separate from the new LLM idle-timeout circuit breaker, which protects against LLM provider stalls during model calls.
Per-agent scoping: breakers are now keyed per agent instance (
tool_{id(self)}_{function_name}), so two agents that expose same-named tools (e.g. search) no longer share one breaker β one agentβs failures canβt degrade the other. Per-agent breakers are auto-pruned from the registry when the Agent is garbage-collected (via weakref.finalize), so a reused instance id canβt inherit a stale OPEN breaker β agent.close() / aclose() merely reclaims that space earlier.Quick Start
1
Works by default
Circuit breaker protection is automatically enabled for every tool call with zero configuration needed.
2
Detect open circuit
When a tool fails 5 times consecutively, subsequent calls return an error dictionary instead of calling the tool.
3
Tune or reset
Customize circuit breaker behavior or reset all breakers between test runs.
How It Works
The
OPEN β HALF_OPEN transition is driven by recovery_timeout or by an enable_health_check=True probe reporting healthy β whichever fires first. Since PR #5330, the probe runs on both sync and async paths.
The async path (achat() / astart()) performs the same OPEN β HALF_OPEN β CLOSED transitions through the shared helpers β _circuit_breaker_precheck runs the pre-check, _circuit_breaker_record records the outcome β so this diagram applies uniformly to chat/start and achat/astart.
Fixed in PR #4969 (async parity): earlier releases counted each async outcome twice, so an async breaker actually opened at half the configured
failure_threshold and closed at half the configured success_threshold. If you tuned these values to compensate on async workloads, you can now restore them to the values youβd use on sync.A further double-record in agent/execution_mixin.py β where breaker.acall(...) records the outcome and two sites below it re-recorded the same failure β was closed in PR #4958. Users who still saw async breakers opening at 3 failures despite failure_threshold=5 after #4969 will now see the configured threshold honoured.CircuitBreaker.acall(...) rather than CircuitBreaker.call(...) so it never blocks the event loop (PR #4469). An error-dict result counts as a failure just like a raised exception β the async path surfaces it to the breaker through a _ToolFailure sentinel, mirroring the sync _ToolFailure wrapper β so five error-dict results open the breaker exactly as five raises would.
Async workflows using asyncio.gather(...) get the same parity: one failing tool opens only its own per-agent breaker and short-circuits, so it wonβt consume retries across every gathered task.
Configuration Options
Custom Health Checks
Circuit breakers close themselves as soon as the backing service is healthy again β you just tell the breaker how to check. Provide any object with ahealth_check() method (sync) or ahealth_check() method (async), and the breaker will probe it on the interval you configured. It now works the same way whether your agent calls tools via agent.start() or await agent.astart().
Pass a health check that implements HealthCheckProtocol and the breaker (or HealthMonitor) probes it on the configured interval.
praisonaiagents.tools.circuit_breaker. Attach an implementation when creating a breaker:
- Canonical method names are
health_check/ahealth_check. - Legacy names
check_health/acheck_healthremain supported β the monitor tries the canonical names first, then falls back. - A plain sync callable (e.g.
lambda: True) also works β it is treated as the sync health check.
As of PR #5050,
HealthMonitor dispatches on the canonical health_check / ahealth_check names. Classes written against the public protocol are no longer silently reported unhealthy.Health checks now recover the circuit on both sync and async paths (PR #5330). Earlier releases only wired the probe into
acall(), and it self-disabled after the first async call by pinning a completed Task forever β so a caller relying on health_check for fast recovery silently fell back to the full recovery_timeout wall-clock wait, no matter how the config was set. The probe is now started when the circuit flips OPEN (from _on_failure()), restarts cleanly on each new OPEN episode, and β for a synchronous callable β runs off the event loop so a blocking probe does not stall unrelated coroutines. Pure-sync callers with no running loop keep the pre-#5330 behaviour: recovery is driven by recovery_timeout alone.What triggers health-check recovery
A health probe recovers an open circuit as soon as it reports healthy, on both sync and async paths.What DOES Trip the Breaker
Async tool timeouts count. When an async tool is cancelled by the per-calltool_timeout, the timeout is recorded on the breaker just like a raised exception or an error-dict result. Five consecutive timeouts open the circuit; the sixth call short-circuits with circuit_open: True.
What Does NOT Trip the Breaker
Circuit breakers ignore certain error types to avoid false positives:approval_deniedβ user rejected the tool callpermission_deniedβ access control failureapproval_errorβ approval workflow errorpolicy_deniedβ policy engine denyguardrail_deniedβ guardrail-blocked tool call
_ToolFailure wrapperβs exclusions β so a denial never counts toward opening the breaker on either path.
Lifecycle
Per-agent breakers are pruned from the global registry automatically when theAgent is garbage-collected β a weakref.finalize callback is registered when each breaker is created. This closes the CPython id()-reuse window without requiring agent.close() / agent.aclose() to be called explicitly.
Calling agent.close() / agent.aclose() triggers _cleanup_circuit_breakers(), which removes every tool_{id(agent)}_* entry from the registry earlier and deterministically β keeping it bounded and preventing a reused id from inheriting a stale OPEN breaker. See Agent Lifecycle Cleanup for the full teardown story.
Async tools
A user calling an unreliable async tool viaawait agent.achat(...) sees the breaker open after five raised exceptions (or five error-dict results); further calls short-circuit with a circuit_open: True error dict, terminating the async retry loop immediately.
tool_timeout counts the same way β the timeout is recorded on the breaker, so five consecutive timeouts open the circuit.
tool_{id(self)}_{function_name}), so two agents sharing a tool name never share a breaker β an OPEN breaker on agent_a never blocks agent_b.
Common Patterns
- Observability
- Custom Config Per Tool
- Global Reset
Enumerate the breakers that belong to a specific agent instance.Retrieving a single breaker by name works too β just build the per-agent key:
Best Practices
Don't disable in production
Don't disable in production
Circuit breakers prevent cascading failures and protect system stability. Keep them enabled in production environments to ensure reliable agent operation.
Monitor circuit breaker stats
Monitor circuit breaker stats
Track circuit breaker statistics in your monitoring systems. Frequent openings indicate underlying tool reliability issues that need attention.
Reset between test runs
Reset between test runs
Per-agent breakers are auto-pruned when the
Agent is collected (via weakref.finalize). Explicit agent.close() reclaims registry space immediately. reset_all_circuit_breakers() remains the sledgehammer for global tests that need a clean slate regardless of GC timing.Surface circuit_open to users
Surface circuit_open to users
When handling
circuit_open: true responses, provide clear user feedback about temporary tool unavailability and suggest retry timeframes or alternative approaches.Async tools that raise still open the breaker
Async tools that raise still open the breaker
An async tool that raises (e.g.
RuntimeError("upstream 503")) counts as a breaker failure just like an error-dict result β the async path records the failure inside its except block. Five raised failures in a row open the breaker so the sixth call short-circuits with circuit_open: True instead of hammering the flaky tool again.Related
Model Failover
Automatic LLM provider switching
Error Handling
Comprehensive error handling strategies

