Files
CoreRasurae 4d3499dcfb 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

246 lines
10 KiB
Markdown

# 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
```bash
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.