Resilix
A declarative execution-policy layer for Python functions. It provides a small decorator DSL for describing how a callable should behave on failure — retries, backoff, fallbacks, result reshaping, timeouts, a circuit breaker, and lifecycle hooks — while keeping the function body itself free of control-flow noise.
- Zero dependencies; standard library only.
- Python 3.10+.
- One policy model works identically on
defandasync def. - Typed (
py.typed,ParamSpec/TypeVar): decorated callables keep their original signature and return type. - Invalid configuration fails at declaration time, not at runtime.
from resilix import protect
@protect.when(TimeoutError, retry=3, backoff="exponential")
def fetch(url): ...
Installation
pip install resilix
From a checkout, for development:
pip install -e ".[dev]" # with the test/type-check toolchain
Model
Resilix is built from five orthogonal concepts. A new kind of reaction is a
new Trigger; the engine does not change.
Event → Trigger → Rule → Policy → Engine
| Concept | Meaning |
|---|---|
Event |
A snapshot of one attempt: ResultEvent or ExceptionEvent. |
Trigger |
Decides whether an event is interesting. |
Rule |
One trigger bound to one action bundle. |
Policy |
An ordered, immutable set of rules; composed with |. |
Engine |
Runs the target, evaluates rules, retries or terminates. |
The protect decorator
protect is a stateless facade. Each method returns a decorator. Stacking
several protect decorators on one function merges them into a single flat
policy (no nested call frames).
| Decorator | Purpose |
|---|---|
protect.when(on, **actions) |
React to an exception type (or a tuple of types). |
protect.when_result(predicate, **actions) |
React to a returned value that satisfies predicate. |
protect.always(cb) |
Run cb exactly once after the whole operation, on success or failure. |
protect.limits(*, timeout=None, deadline=None) |
Per-attempt timeout and total-budget deadline. |
protect.timeout(seconds, *, deadline=None) |
Cap a single attempt; a slow call becomes TimeoutError. |
protect.circuit(*, failures=5, reset_timeout=30.0, on=Exception) |
Wrap the function in a circuit breaker. |
protect.methods(policy, *, include=None, exclude=(), circuit=None) |
Apply one policy across a class's methods. |
Actions
when and when_result accept the same keyword actions. A rule allows retry
and pacing options plus exactly one terminal action.
| Keyword | Effect |
|---|---|
retry |
Total attempts, including the first (int or a Retry). |
delay |
Base pause between attempts (float, or delay(exc) -> float). |
backoff |
Growth curve: "constant", "linear", "exponential". |
jitter |
Multiply the pause by a random factor in [0.5, 1.5]. |
max_delay |
Ceiling applied after backoff and jitter. |
mutate |
mutate(kwargs, error) run before each retry; edits in place or returns a dict. |
notify |
Called with an Event on every matched attempt. |
return_ |
Terminal. Return a fixed value (including None). |
fallback |
Terminal. Try one or more alternatives; return the first success. |
transform |
Terminal. Reshape the outcome: fn(result) or fn(exc). |
raise_ |
Terminal. Raise a new exception; the original is kept as __cause__. |
from resilix import protect
# Retry, then re-raise the last exception.
@protect.when(TimeoutError, retry=3)
def ping(): ...
# Exponential backoff with jitter and a ceiling.
@protect.when(ConnectionError, retry=5, backoff="exponential", jitter=True, max_delay=10)
def call(): ...
# Fall back to alternatives, then a fixed default.
@protect.when(ConnectionError, fallback=[replica, cache], return_={})
def read(key): ...
# React to a value rather than an exception.
@protect.when_result(lambda r: r.status_code == 429, retry=3, delay=1.0, return_="rate_limited")
def request(): ...
# Wrap a low-level error, preserving the cause.
@protect.when(OSError, raise_=lambda exc: StorageError("io failed"))
def save(row): ...
# Run a finaliser once, whatever happens.
@protect.always(close_connection)
def query(): ...
Policies
A Policy is an ordered, immutable set of rules. Build one directly, use it as
a decorator, and combine policies with |. On composition the most specific
exception (deepest in the MRO) wins a tie; time limits merge to the strictest
(smallest) value; always finalisers run in sequence.
from resilix import Policy, compose
network = Policy(on=TimeoutError, retry=3, backoff="exponential")
missing = Policy(on=KeyError, return_=None)
resilient = network | missing # or compose(network, missing)
@resilient
def get(user_id): ...
compose(*policies) and all_of(iterable) are thin helpers over |.
Retry
Retry is a reusable descriptor for the pacing of a policy.
from resilix import Retry, Policy
backoff = Retry(attempts=5, delay=0.2, backoff="exponential", jitter=True, max_delay=10)
@Policy(on=OSError, retry=backoff, fallback=from_disk)
def load(path): ...
attempts is the total number of attempts including the first (attempts=1
never retries). BackoffStrategy is the literal type of the backoff field.
Invalid values (attempts < 1, negative delay, unknown backoff) raise
RetryConfigurationError on construction.
Circuit breaker
CircuitBreaker is a thread-safe decorator with its own state, separate from
Policy. After failures consecutive matching errors the circuit opens and
rejects calls with CircuitOpenError for reset_timeout seconds, then allows
one half-open trial: success closes it, failure reopens it.
from resilix import CircuitBreaker
breaker = CircuitBreaker(failures=5, reset_timeout=30, on=ConnectionError)
@breaker
def read(): ...
@breaker
def write(x): ...
breaker.state # "closed" | "open" | "half_open"
Use one breaker object across several functions to give them shared state.
protect.circuit(...) is a convenience wrapper that constructs a breaker for a
single function.
Class-level policies
protect.methods(policy, ...) applies one policy across a class's methods.
from resilix import Policy, CircuitBreaker, protect
io = Policy(on=(TimeoutError, ConnectionError), retry=3, backoff="exponential")
@protect.methods(io, exclude=("ping",))
class StorageClient:
def read(self, key): ...
def write(self, key, val): ...
def ping(self): ... # left unprotected
def _connect(self): ... # private — unprotected by default
Selection:
- By default only local, public methods (defined on the class itself, not inherited) are wrapped.
- Dunder names (
__x__), private names (_x), and descriptors (property,classmethod,staticmethod) are skipped unless named ininclude. include=(...)is an exact whitelist that overrides the public-only default.exclude=(...)subtracts from the default.- A name in
include/excludethat is not defined on the class raisesPolicyConfigurationErrorat declaration time.
Passing circuit=CircuitBreaker(...) additionally wraps each selected method in
the same breaker, so its state is shared across methods and instances. The
layering is circuit(policy(method)), so retries inside the policy count as a
single failure to the breaker. The decorator returns the same class object; it
does not create a subclass.
Async
The same policies apply to coroutines. Delays use asyncio.sleep; predicates,
transforms, mutations, and fallbacks may be sync or async. asyncio.CancelledError
is always propagated and cannot be swallowed by any rule.
@protect.when(TimeoutError, retry=3, delay=0.1, fallback=from_cache)
async def fetch(url): ...
Behavioural contracts
retry=Ncounts the first call:retry=3is up to three attempts total,retry=1runs once and never retries.when_result(..., retry=N)requires a terminal action (return_,fallback,transform, orraise_); otherwise it raisesPolicyConfigurationErrorat declaration time.delay/backoff/jitter/max_delayrequireretry; using them alone raisesPolicyConfigurationError.rethrow=is a deprecated synonym ofraise_=and emitsDeprecationWarning.protect.timeoutdiffers by execution model: the async engine cancels the coroutine viaasyncio.wait_for; the sync engine stops waiting but cannot kill the daemon thread, so a slow synchronous call keeps running in the background.CircuitBreakeris not part ofPolicy: it cannot be merged with|, has noreturn_/fallback, and stacks as a nested decorator.notifyandalwayserrors are discarded so a broken hook cannot break the call; in afallbackchain only the last error propagates.protect.methodsdoes not follow inheritance: a method overridden in a subclass is unprotected.notifyhooks seeselfasevent.args[0].
A rendered guide with side-by-side examples is in docs/usage.html.
Public API
Everything below is exported from the top-level resilix package.
- Decorators / policies:
protect,Policy,compose,all_of. - Retry:
Retry,BackoffStrategy. - Circuit breaker:
CircuitBreaker. - Rules & triggers:
Rule,build_rule,Trigger,ExceptionTrigger,ResultTrigger. - Events:
Event,ExceptionEvent,ResultEvent. - Sentinel:
UNSET(distinguishes "returnNone" from "no value set"). - Errors:
ResilixError(base),PolicyConfigurationError,RetryConfigurationError,AsyncCallbackInSyncContextError,PolicyExecutionError,CircuitOpenError.
| Error | Raised when |
|---|---|
PolicyConfigurationError |
A policy is declared with impossible or conflicting options. |
RetryConfigurationError |
A Retry is built with invalid parameters. |
AsyncCallbackInSyncContextError |
An async callback is attached to a synchronous target. |
PolicyExecutionError |
The engine reached an impossible state at runtime. |
CircuitOpenError |
A call was rejected because the circuit is open. |
Development
pip install -e ".[dev]"
pytest # unit, sync, async, composition, security, class-level
mypy # strict typing
Runnable examples are in examples/; adversarial and concurrency
tests are in tests/security/.
License
MIT.
Metadata
Release files for resilix 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| resilix-1.0.0.tar.gz | 31.0 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| resilix-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 65.0 kB
Release files / resilix-1.0.0.tar.gz
| Download URL | resilix-1.0.0.tar.gz |
|---|---|
| Size | 31.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
007a5954bafd9aa07e987998d0293df44683dfb83174d620256f3b10940c8580
|
|
BLAKE2b-256 checksum How to use checksums |
c07cf099f02934a00409ddf01e1fa832d3222a12abafc7e945a937882410e6c8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Release files / resilix-1.0.0-py3-none-any.whl
| Download URL | resilix-1.0.0-py3-none-any.whl |
|---|---|
| Size | 34.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
475885cc6a36be558e5ed54949f67a6800cb3b62d127e9b75ab4de92beb68f96
|
|
BLAKE2b-256 checksum How to use checksums |
4fe26288504299bbded5f4924d010ae488ce13c6412fb8a4c665985b50acb582
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Security for resilix 1.0.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.