Wire per-service retry policies and circuit breakers into the service layer via ServiceRetryWiring, backed by ServiceRetryPolicyRegistry and configurable through Settings environment variables. Production hardening from code review: - Fix TOCTOU race in CircuitBreaker._on_success (half-open state) - Add half-open probe limit to prevent unbounded concurrent requests - Track all exception types for circuit breaker failure counting - Detect async callables wrapped in functools.partial and callable objects - Enforce spec-compliant 2s minimum for linear backoff strategy - Enforce 0.1s floor for fixed backoff strategy - Add retry amplification guard via contextvars nesting depth tracking - Cap total retry wall-clock time at 300s (MAX_RETRY_TOTAL_TIMEOUT) - Sanitize exception messages in retry logs to prevent secret leakage - Fix wrap_service_method TOCTOU by holding cache lock for full operation - Deep-copy default policies to prevent cross-policy mutation - Warn on unknown override keys in apply_overrides - Guard apply_overrides against non-dict and deeply nested JSON values - Read circuit breaker state under lock in is_circuit_open - Catch RecursionError in JSON config parsing - Add total_timeout + nesting guard to retry_service_operation decorator - Extend secret sanitization to Authorization headers, private_key, connection_string, and access_key patterns - Enforce 0.1s floor on jitter backoff strategy - Cache wait strategies per service in ServiceRetryWiring (M3) - Reset failure_count to 0 when entering half-open from open (M6) - Use cached _get_wait_strategy() in execute()/async_execute() - Move circuit-open logging out of _on_failure lock scope to prevent holding the lock during potentially slow I/O (F1) - Pass total_timeout=MAX_RETRY_TOTAL_TIMEOUT to wrap_service_method retry_service_operation call for consistency with execute() (F4) - Capture failure_count into local variable inside lock scope before logging outside the lock, preventing stale reads from concurrent threads in CircuitBreaker.call() and async_call() (F1) - Deep-copy module-level DEFAULT_DATABASE_RETRY and DEFAULT_CIRCUIT_BREAKER in ServiceRetryPolicyRegistry.get() for auto-generated unknown service policies, preventing shared mutable state corruption (F1) - Unify CircuitBreaker to a single threading.Lock for sync and async (P1-1) - Restore BaseException permit in half-open path to prevent permit leak (P1-6) - Prevent CircuitBreakerOpen cascading into failure_count (S2) - Protect all logger calls with contextlib.suppress (S3, S4) - Replace time.time() with time.monotonic() for monotonic timing (S5) - Add distinct log events for half-open and closed transitions (S11, S12) - Track pre-existing services so second apply_settings_defaults only targets newly registered services (P1-2) - Lazy circuit breaker creation via _get_or_create_cb() (P1-3) - Reject async callables in sync execute() with TypeError (P1-5) - Strengthen retry predicate to retry_if_exception_type(Exception) & retry_if_not_exception_type(CircuitBreakerOpen) (S1) - Add lock on _get_wait_strategy cache access (P2-16) - Truncate raw JSON to 80 chars in override warning (P2-17) - Warn on non-dict JSON overrides (P2-29) - Debug log for nesting guard bypass (S13) - Deep-copy from get() and all_policies() in registry (P1-4) - Thread-safe registry with threading.Lock (P2-15) - Robust exception handling in apply_overrides get() (P2-18) - Log ValidationError details on override failure (P2-19) - Sanitize service_name via _safe_service_name() (P2-28) - Warn on non-dict sub-key values in overrides (P2-30) - Allowlist for is_read_only_plan_operation phases (P2-10) - Cap retry_auto_debug sleep at 60s (P2-11) - Use is-not-None instead of falsy checks for error values (P2-12) - Extend secret regex with bearer, session_id, auth_token, refresh_token, client_secret patterns (P2-25) - Pre-truncate error messages to 2000 chars before regex (P2-26) - Add upper bounds on retry Settings fields (P2-7) - Add cross-field validator max_delay >= base_delay (P2-8) - Case-insensitive backoff strategy validation (P2-21) - Add half_open_max_successes setting (S10) - Remove phantom ContextFragment from services __all__ (ImportError fix) - Export ServiceRetryWiring from application.services package - Include sanitised error context in TypeError logging fallback - Initialise RetryContext.attempt_count to 1 for bare context-manager usage - Introduce CircuitBreakerState StrEnum replacing raw string literals - Fix vacuous CircuitBreakerOpen propagation assertions in BDD steps - Replace tautological logging test with structlog capture verification - Assert circuit breaker existence instead of silently skipping on None - Add Unicode control-char rejection validator to ServiceRetryPolicy.service_name - Add name parameter with service= in all log calls - Add extra="forbid" to all 3 Pydantic models - Deep-copy _SERVICE_DEFAULTS construction - Key normalisation (.strip()) in get() and apply_overrides() - Add cooldown <= recovery_timeout validator - Async guard on RetryContext.execute() - Nesting guard on RetryContext.execute()/async_execute() - stop_after_delay(300.0) on RetryContext - retry_auto_debug async-only guard, dict result fix, sleep guard - Retry-attempt logging in RetryContext - Module-level docs for contextlib.suppress(TypeError) rationale - Exhaustion log on retry failure - Startup log in __init__; name=service_name to CircuitBreaker - log_after_retry guarded to not fire on first-attempt success - get_retry_decorator now includes logging callbacks - Changed retry_backoff_strategy from str to RetryStrategy StrEnum Closes #313
10 KiB
Retry Policy Configuration
Overview
CleverAgents integrates retry policies and circuit breakers into service layer operations. Each service has a default resilience policy that can be overridden via environment variables or a JSON config string.
Retry is applied only to idempotent operations (repository reads, validation calls). Apply/write operations are never retried.
Both synchronous and asynchronous service methods are supported. Use
ServiceRetryWiring.execute() for sync callables and
ServiceRetryWiring.async_execute() for async callables. The
retry_service_operation decorator auto-detects coroutine functions
and uses AsyncRetrying internally.
Passing an async callable to ServiceRetryWiring.execute() raises
TypeError — use async_execute() instead. Similarly,
RetryContext.execute() raises TypeError for async callables.
All state transitions in CircuitBreaker are thread-safe — an internal
lock guards failure counts and state changes without holding the lock
during the actual function execution.
Configuration Keys
| Key | Env Var | Default | Description |
|---|---|---|---|
retry_max_attempts |
CLEVERAGENTS_RETRY_MAX_ATTEMPTS |
3 | Maximum retry attempts |
retry_base_delay |
CLEVERAGENTS_RETRY_BASE_DELAY |
1.0 | Base delay (seconds) |
retry_max_delay |
CLEVERAGENTS_RETRY_MAX_DELAY |
60.0 | Max delay (seconds) |
retry_jitter |
CLEVERAGENTS_RETRY_JITTER |
true | Add random jitter |
retry_backoff_strategy |
CLEVERAGENTS_RETRY_BACKOFF_STRATEGY |
exponential | Backoff: exponential, linear, fixed, jitter, none |
circuit_breaker_failure_threshold |
CLEVERAGENTS_CB_FAILURE_THRESHOLD |
5 | Failures before circuit opens |
circuit_breaker_recovery_timeout |
CLEVERAGENTS_CB_RECOVERY_TIMEOUT |
60.0 | Seconds before half-open |
circuit_breaker_cooldown |
CLEVERAGENTS_CB_COOLDOWN |
30.0 | Min seconds between recovery |
retry_service_overrides |
CLEVERAGENTS_RETRY_SERVICE_OVERRIDES |
{} | JSON per-service overrides |
Per-Service Override Example
export CLEVERAGENTS_RETRY_SERVICE_OVERRIDES='{
"plan_service": {
"retry": {"max_attempts": 5, "base_delay": 2.0},
"circuit_breaker": {"failure_threshold": 3}
},
"vector_store_service": {
"retry": {"max_attempts": 7, "backoff_strategy": "jitter"}
}
}'
Default Per-Service Policies
| Service | Category | Max Attempts | Backoff | Jitter |
|---|---|---|---|---|
plan_service |
provider | 3 | jitter | yes |
plan_lifecycle_service |
database | 3 | fixed | no |
context_service |
database | 3 | fixed | no |
session_service |
database | 3 | fixed | no |
checkpoint_service |
database | 3 | fixed | no |
trace_service |
database | 3 | fixed | no |
actor_service |
database | 3 | fixed | no |
resource_registry_service |
database | 3 | fixed | no |
decision_service |
database | 3 | fixed | no |
vector_store_service |
network | 5 | exponential | yes |
error_recovery_service |
database | 3 | fixed | no |
Circuit Breaker States
closed -> open (after failure_threshold consecutive failures)
open -> half-open (after recovery_timeout seconds AND cooldown_seconds
since last half-open attempt; failure_count is reset
to 0 on entry to prevent stale counts from
immediately re-opening the circuit)
half-open -> closed (after half_open_max_successes consecutive successes)
half-open -> open (on any failure)
Both cooldown_seconds and half_open_max_successes are wired from
per-service JSON overrides into the CircuitBreaker constructor.
Global environment variable overrides (CLEVERAGENTS_CB_*) do not
currently include half_open_max_successes — this field is only
configurable via the JSON per-service override path.
The reset_circuit() helper atomically clears all counters and
timestamps (including last_failure_time) under the circuit breaker's
internal lock.
Half-Open Probe Limit
When the circuit enters half-open state, only half_open_max_successes
concurrent requests are admitted as probes. Additional requests receive
CircuitBreakerOpen immediately. This prevents a flood of requests
from hitting a recovering service.
Failure Tracking
All exceptions (not just the configured expected_exception type) are
counted towards the failure threshold. This ensures the circuit breaker
opens for any type of recurring failure.
Concurrent Safety in Half-Open State
A successful probe in half-open state only counts towards recovery if the circuit is still half-open at the time the lock is acquired. If a concurrent failure has already re-opened the circuit, the stale success is ignored — the circuit remains open.
Per-Service Circuit Breaker Toggle
Each service's CircuitBreakerConfig includes an enabled boolean
(default True). Setting enabled: false in a per-service override
disables circuit breaker protection for that service without removing
it from the registry. When disabled, operations run without any
circuit breaker wrapping.
Structured Logs
Every retry attempt emits a structured log with:
service: Service nameoperation: Operation nameattempt: Attempt numbererror: Error message (if applicable)
Circuit breaker opens emit:
service: Service namefailure_count: Number of consecutive failures
Idempotency Guard
The retry_on_idempotent_only flag (default True) ensures that
non-idempotent operations (writes, applies) are never retried. The
is_read_only_plan_operation() guard additionally prevents retries on
tool execution writes in read-only plans. Only string values of
plan_phase are evaluated; non-string truthy values (e.g. True, 1)
are treated as not read-only to prevent false positives.
Thread Safety
CircuitBreaker uses an internal threading.Lock to guard all state
transitions. The lock is not held during the actual function call,
so long-running operations do not block other threads. Logging that
the circuit has opened is also emitted after the lock is released to
avoid holding the lock during potentially slow I/O.
The ServiceRetryWiring._decorator_cache is guarded by a separate
threading.Lock to prevent TOCTOU races under concurrent access. The
entire lookup-or-create operation runs under the lock.
is_circuit_open() reads the circuit state under the breaker's lock,
making it safe to call from any thread.
Backoff Floor
When the backoff strategy is "exponential" and base_delay is below
0.1, the effective delay is clamped to 0.1 seconds to prevent
instant retry hammering.
When the strategy is "fixed", a minimum floor of 0.1 seconds is
enforced.
When the strategy is "linear", a minimum floor of 2.0 seconds is
enforced per the specification. Note that the "linear" strategy uses
wait_fixed (constant delay), not a linearly increasing delay —
every retry waits the same duration.
When the strategy is "jitter", a minimum floor of 0.1 seconds is
enforced, consistent with the exponential and fixed floors.
Retry Amplification Guard
A contextvars-based nesting guard prevents exponential retry
multiplication when service A (retrying) calls service B (also
retrying). At nesting depth >= 1, the inner call executes exactly once
(no retry), while still routing through the circuit breaker if present.
The nesting guard is applied in both execute()/async_execute() and
the retry_service_operation decorator, ensuring consistent protection
regardless of the calling pattern.
Total Timeout Cap
All retry attempts are bounded by a total wall-clock timeout of
MAX_RETRY_TOTAL_TIMEOUT (300 seconds / 5 minutes). This prevents
pathological configurations from blocking a thread indefinitely.
The retry_service_operation decorator accepts a total_timeout
parameter (default 300.0 seconds) providing the same wall-clock cap.
Error Sanitization
Exception messages logged during retry attempts are sanitized to prevent leaking secrets. The following patterns are redacted:
- URL credentials:
://user:pass@host→://***@host - Key-value secrets:
api_key=...,password=...,secret=...,credential=...,token=...,private_key=...,connection_string=...,access_key=...,bearer=...,session_id=...,auth_token=...,refresh_token=...,client_secret=...→<key>=*** - Authorization headers:
Authorization: Bearer ...,x-api-key: ...→<header>: *** - Dict-literal secrets:
{'api_key': '...', 'token': '...'}→{'api_key': '***', 'token': '***'}(Python dict repr format)
Messages longer than 200 characters are truncated with ... appended.
Async Callable Detection
The retry_service_operation decorator detects async callables using
_is_async_callable(), which handles asyncio coroutine functions,
functools.partial wrapping async functions, and callable objects with
an async __call__ method.
Override Safety
Per-service JSON overrides (retry_service_overrides) have a maximum
length of 10 000 characters. The service_name key is ignored in
overrides to prevent key/value mismatches in the policy registry.
Invalid overrides that fail Pydantic validation are logged and skipped.
Non-dict override values are logged and ignored. Unrecognised
top-level keys in override dicts emit a warning so typos are not
silently lost.
Wait Strategy Caching
Wait strategies (tenacity wait objects) are pre-built during
ServiceRetryWiring.__init__() and cached per service name.
execute() and async_execute() reuse the cached strategy via
_get_wait_strategy() instead of rebuilding per call. Unknown
services that are not registered at init time are lazily built and
cached on first access.
Default Isolation
Per-service default policies are deep-copied on registry creation, so
mutation of one service's sub-models never corrupts shared defaults or
other policies. Auto-generated policies for unknown services (created
on first get() call) are likewise deep-copied from the module-level
DEFAULT_DATABASE_RETRY and DEFAULT_CIRCUIT_BREAKER constants.