Metadata-Version: 2.4
Name: resilix
Version: 1.0.0
Summary: Declarative execution-policy layer for Python (retry, backoff, fallback, transform, timeout, circuit breaker, hooks).
Author-email: Yaroslav Zhomyruk <zhomyruk.yaroslav.v@gmail.com>
Maintainer-email: Yaroslav Zhomyruk <zhomyruk.yaroslav.v@gmail.com>
License: MIT
Project-URL: Homepage, https://github.com/0-EternalJunior-0/resilix
Project-URL: Repository, https://github.com/0-EternalJunior-0/resilix
Project-URL: Issues, https://github.com/0-EternalJunior-0/resilix/issues
Project-URL: Changelog, https://github.com/0-EternalJunior-0/resilix/blob/main/CHANGELOG.md
Keywords: retry,resilience,policy,fallback,circuit-breaker,backoff,timeout,async,decorator
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=7.4; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: mypy>=1.8; extra == "dev"
Requires-Dist: pyright>=1.1.350; extra == "dev"
Requires-Dist: typing-extensions>=4.7; extra == "dev"
Requires-Dist: build>=1.0; extra == "dev"
Requires-Dist: twine>=5.0; extra == "dev"
Dynamic: license-file

# Resilix

[![PyPI](https://img.shields.io/pypi/v/resilix.svg)](https://pypi.org/project/resilix/)
[![Python](https://img.shields.io/pypi/pyversions/resilix.svg)](https://pypi.org/project/resilix/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)

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.

```python
from resilix import protect

@protect.when(TimeoutError, retry=3, backoff="exponential")
def fetch(url): ...
```

## Installation

```bash
pip install resilix
```

From a checkout, for development:

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

```python
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
@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`](./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

```bash
pip install -e ".[dev]"
pytest            # unit, sync, async, composition, security, class-level
mypy              # strict typing
```

Runnable examples are in [`examples/`](./examples); adversarial and concurrency
tests are in [`tests/security/`](./tests/security).

## License

MIT.
