Architecture¶
graph LR
A["@retryable / @circuit_breaker / @timeout"] -->|policy on fn + intercepted_by| B[Interceptors]
B --> C[RetryInterceptor]
B --> D[CircuitBreakerInterceptor]
B --> E[TimeoutInterceptor]
C & D & E --> F[ResilienceSettings] | Module | Responsibility |
|---|---|
decorators.py | Store the policy on the function; attach the interceptor |
interceptors.py | Execute the policies (sync + async paths) |
config.py | ResilienceSettings (enabled switch) |
exceptions.py | RetryExhaustedError, CircuitOpenError |
Design decisions¶
Policy on the function, logic in the interceptor. Decorators are import-light markers (the pico-pydantic idiom); the interceptors are container singletons with settings injected. Testable, and the policy is introspectable on the class.
@retryable runs innermost. Retry attempts after the first re-invoke the method directly. Rather than re-dispatching the whole chain per attempt (complex, surprising side effects), the rule is documented and enforced by convention: @retryable on top, other policies wrap the loop. A regression test pins this behavior.
@timeout is async-only, rejected at decoration time. Python threads cannot be cancelled: a sync timeout would return control while the work keeps running — a lie plus a leak. Failing at import beats failing in production.
Circuit state per method, in the singleton. Keyed by module.Class.method, one lock. Two instances of a component share the circuit — the protected resource is the dependency, not the instance.
Half-open allows exactly one trial. On entry the failure counter is set to threshold - 1: a single failure reopens, a success resets. No separate state enum to keep in sync.