Files
cleveragents-core/docs/reference/retry_policy.md
T
CoreRasurae 4d3499dcfb
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 16s
CI / build (pull_request) Successful in 17s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 35s
CI / typecheck (pull_request) Successful in 38s
CI / unit_tests (pull_request) Successful in 2m43s
CI / integration_tests (pull_request) Successful in 3m20s
CI / docker (pull_request) Successful in 51s
CI / coverage (pull_request) Successful in 6m18s
CI / build (push) Successful in 14s
CI / lint (push) Successful in 21s
CI / quality (push) Successful in 29s
CI / typecheck (push) Successful in 35s
CI / security (push) Successful in 36s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 3m17s
CI / docker (push) Successful in 9s
CI / integration_tests (push) Successful in 3m29s
CI / coverage (push) Successful in 7m3s
CI / benchmark-publish (push) Successful in 19m50s
CI / benchmark-regression (pull_request) Successful in 40m6s
feat(async): wire retry policies into services
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
2026-03-11 17:42:13 +00:00

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 name
  • operation: Operation name
  • attempt: Attempt number
  • error: Error message (if applicable)

Circuit breaker opens emit:

  • service: Service name
  • failure_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.