Skip to main content

Resilix

PyPI Python License: MIT

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 def and async 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 in include.
  • include=(...) is an exact whitelist that overrides the public-only default.
  • exclude=(...) subtracts from the default.
  • A name in include/exclude that is not defined on the class raises PolicyConfigurationError at 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=N counts the first call: retry=3 is up to three attempts total, retry=1 runs once and never retries.
  • when_result(..., retry=N) requires a terminal action (return_, fallback, transform, or raise_); otherwise it raises PolicyConfigurationError at declaration time.
  • delay / backoff / jitter / max_delay require retry; using them alone raises PolicyConfigurationError.
  • rethrow= is a deprecated synonym of raise_= and emits DeprecationWarning.
  • protect.timeout differs by execution model: the async engine cancels the coroutine via asyncio.wait_for; the sync engine stops waiting but cannot kill the daemon thread, so a slow synchronous call keeps running in the background.
  • CircuitBreaker is not part of Policy: it cannot be merged with |, has no return_/fallback, and stacks as a nested decorator.
  • notify and always errors are discarded so a broken hook cannot break the call; in a fallback chain only the last error propagates.
  • protect.methods does not follow inheritance: a method overridden in a subclass is unprotected. notify hooks see self as event.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 "return None" 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)

Source distribution for resilix 1.0.0
File Size Uploaded Provenance
resilix-1.0.0.tar.gz 31.0 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for resilix 1.0.0
File Interpreter ABI Platform
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.

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page