# interlock — full documentation

> A modern circuit breaker for Python: sync and async in a single class,
> sliding-window failure-rate and slow-call detection, a type-safe decorator
> API, and a transparent per-host httpx2 transport. Zero-dependency core
> (standard library only); integrations ship as optional extras.

This file inlines every documentation page in reading order. It is generated
from the Markdown sources by ``scripts/build_llms_full.py`` — edit the pages in
``docs/``, not this file.


---

<!-- source: docs/getting-started.md -->

# Getting started

## Install

=== "uv"

    ```bash
    uv add interlock-cb
    ```

=== "pip"

    ```bash
    pip install interlock-cb
    ```

=== "poetry"

    ```bash
    poetry add interlock-cb
    ```

The core is pure standard library. External integrations are optional extras —
add the ones you need (same names with `pip install` / `poetry add`):

```bash
uv add 'interlock-cb[otel]'    # OpenTelemetry metrics listener
uv add 'interlock-cb[httpx2]'  # per-host httpx2 transport
uv add 'interlock-cb[fastapi]' # CircuitOpenError -> 503 + Retry-After
uv add 'interlock-cb[redis]'   # shared distributed state
```

## Create a breaker

A breaker is named and configured once, then reused:

```python
from interlock import CircuitBreaker, Config

breaker = CircuitBreaker(
    name='payments',
    config=Config(failure_rate_threshold=0.5, minimum_number_of_calls=20),
)
```

The defaults follow resilience4j: trip at a 50% failure rate over at least 10
calls, stay open for 60s, then admit up to 10 probe calls (one at a time) and
decide from their outcomes. See [Configuration](guides/configuration.md) for
every option.

## Three ways to protect work

All three run over the same `call()` primitive.

### Decorator

```python
@breaker
def charge(amount: int) -> str:
    return gateway.charge(amount)
```

The decorator preserves the wrapped signature and its sync/async nature — type
checkers still see `charge` as `(int) -> str`.

### `breaker.call`

```python
result = breaker.call(gateway.charge, 100)
```

### Context manager

```python
with breaker:
    gateway.charge(100)
```

!!! note "Contract difference"
    The decorator and `call` see a callable, so result-based classification and
    slow-call detection both apply. The context manager sees only the block —
    its exception and duration — so classification by **return value** is not
    available there. Need result-based classification? Use the decorator or
    `call`.

## Async

The same instance handles async. The decorator and `call` detect a coroutine
function; the instance is also an async context manager:

```python
@breaker
async def fetch(url: str) -> bytes:
    return await client.get(url)

result = await breaker.call(client.get, url)

async with breaker:
    await client.get(url)
```

## Handle rejections

When the circuit is not closed, the call is rejected with `CircuitOpenError`:

```python
from interlock import CircuitOpenError

try:
    breaker.call(gateway.charge, 100)
except CircuitOpenError as exc:
    # exc.breaker_name, exc.retry_after (seconds, may be None), exc.last_failure
    raise
```

## Inspect state

```python
breaker.state            # State.CLOSED / OPEN / HALF_OPEN / ...
breaker.snapshot()       # WindowSnapshot: total_calls, failed_calls, slow_calls,
                         # .failure_rate, .slow_call_rate
```

## Next steps

- [Configuration](guides/configuration.md)
- [States & manual control](guides/states.md)
- [Failure classification](guides/failure-classification.md)
- [Observability](guides/observability.md)
- [httpx2 integration](integrations/httpx2.md)

---

<!-- source: docs/guides/configuration.md -->

# Configuration

`Config` is an immutable (frozen) dataclass validated on construction. Pass it
to a `CircuitBreaker` or share it across a `Registry`. All fields are
keyword-only.

```python
from interlock import Config
from interlock import WindowType

config = Config(
    failure_rate_threshold=0.5,
    minimum_number_of_calls=20,
    slow_call_duration_threshold=2.0,
    slow_call_rate_threshold=1.0,
    permitted_calls_in_half_open=10,
    max_concurrent_probes=1,
    wait_duration_in_open=30.0,
    window_type=WindowType.COUNT_BASED,
    window_size=100,
)
```

## Fields

| Field | Default | Meaning |
|-------|---------|---------|
| `failure_rate_threshold` | `0.5` | Trip when the failure rate reaches this fraction. Range `(0, 1]`. |
| `minimum_number_of_calls` | `10` | Minimum calls in the window before a rate is trusted. Guards against `1/1 = 100%`. |
| `slow_call_duration_threshold` | `60.0` | Calls at or above this many seconds are **slow**. |
| `slow_call_rate_threshold` | `1.0` | Trip when the slow-call rate reaches this fraction. Range `(0, 1]`. |
| `permitted_calls_in_half_open` | `10` | Probe calls allowed while `HALF_OPEN`. |
| `max_concurrent_probes` | `1` | Cap on **simultaneous** probes in `HALF_OPEN`. Must be in `[1, permitted_calls_in_half_open]`. |
| `wait_duration_in_open` | `60.0` | Seconds to stay `OPEN` before the first probe is allowed. |
| `auto_transition` | `False` | When `True`, a timer moves the breaker `OPEN → HALF_OPEN` once the wait elapses, instead of waiting for the next call. See [States](states.md#proactive-transition-auto_transition). |
| `window_type` | `COUNT_BASED` | `COUNT_BASED` or `TIME_BASED`. |
| `window_size` | `100` | Last N calls (count-based) or last N seconds (time-based). |

Validation raises `ValueError` eagerly for out-of-range or inconsistent values,
so a misconfigured breaker fails at construction rather than in production.

## Windows

- **Count-based** keeps the last `window_size` calls. Predictable memory,
  independent of traffic rate. The default.
- **Time-based** keeps calls from the last `window_size` seconds. The right
  choice for high-throughput services where "last N calls" is a moving target.

```python
from interlock import Config, WindowType

# Trip on a 50% failure rate observed over the last 30 seconds.
Config(window_type=WindowType.TIME_BASED, window_size=30)
```

## Why slow calls matter

A dependency that answers slowly but never errors will never trip a
failure-rate breaker, yet it still exhausts your timeouts and threads.
Slow-call detection treats latency as a first-class failure signal. By default
`slow_call_rate_threshold=1.0` means slowness alone never trips the breaker
until you tune it down — safe to leave on while you observe.

## Sharing config with a Registry

```python
from interlock import Config, Registry

registry = Registry(config=Config(minimum_number_of_calls=20))

payments = registry.get('payments')                       # shared default
search = registry.get('search', config=Config(window_size=500))  # per-name override
```

The override applies only when the breaker is first created; later `get` calls
with the same name return the existing instance and ignore the `config`
argument.

---

<!-- source: docs/guides/states.md -->

# States & manual control

A breaker has three core states plus three operator overrides.

## Core lifecycle

```mermaid
stateDiagram-v2
    CLOSED --> OPEN: failure/slow rate crosses threshold
    OPEN --> HALF_OPEN: first call after wait_duration_in_open (or timer, if auto_transition)
    HALF_OPEN --> CLOSED: probe round passes
    HALF_OPEN --> OPEN: probe round fails
```

- **`CLOSED`** — traffic flows; outcomes are recorded. When the failure rate (or
  slow-call rate) crosses its threshold over at least `minimum_number_of_calls`,
  the breaker trips to `OPEN`.
- **`OPEN`** — calls are rejected immediately with `CircuitOpenError`. After
  `wait_duration_in_open` seconds, the **next** call lazily moves the breaker to
  `HALF_OPEN`. Enable [`auto_transition`](#proactive-transition-auto_transition)
  to have a timer make that move on its own.
- **`HALF_OPEN`** — up to `permitted_calls_in_half_open` probe calls are
  admitted, with a cap on how many run concurrently, so a barely-recovered
  dependency is not hit by the full parallel load at once. Once the round
  completes, the breaker decides from the probes' outcomes using the same
  thresholds as `CLOSED`: rates below the thresholds close it, at or above
  re-open it. Calls beyond the probe caps are rejected while the round runs.

## Proactive transition (`auto_transition`)

By default the `OPEN → HALF_OPEN` move is **lazy**: it happens on the first call
after `wait_duration_in_open` elapses. A low-traffic service can therefore sit in
`OPEN` longer than necessary, and — since nothing changes until that call — the
state-change event is not emitted, leaving a blind spot on dashboards.

Set `auto_transition=True` to arm a timer that performs the move on its own when
the wait elapses, emitting `on_state_change` without waiting for a call:

```python
from interlock import CircuitBreaker, Config

breaker = CircuitBreaker(
    name='payments',
    config=Config(wait_duration_in_open=30.0, auto_transition=True),
)
# 30s after opening, the breaker moves to HALF_OPEN and emits the event,
# even if no call arrives.
```

The lazy path stays authoritative: the timer only flips the state (it admits no
probe), so the first real call still becomes the first probe. If a call arrives
exactly as the timer fires, a lock ensures the transition and its event happen
exactly once. The timer is cancelled automatically on `reset()`, `force_open()`,
or when a call makes the move first.

The timer is a daemon thread, used uniformly for sync and async breakers (the
breaker's critical sections are guarded by a `threading.Lock`, never an event
loop), so a pending timer never blocks interpreter shutdown.

## Operator overrides

Three special states are set manually and stay until you `reset()`:

| Method | State | Behaviour |
|--------|-------|-----------|
| `breaker.force_open()` | `FORCED_OPEN` | Reject all traffic regardless of metrics. |
| `breaker.disable()` | `DISABLED` | Admit all traffic, record nothing — the breaker is a no-op. |
| `breaker.metrics_only()` | `METRICS_ONLY` | Admit all traffic, record metrics, but never trip. |
| `breaker.reset()` | `CLOSED` | Return to closed with a fresh, empty window. |

```python
breaker.metrics_only()   # observe in production without enforcing
# ... inspect breaker.snapshot() until thresholds look right ...
breaker.reset()          # start enforcing with a clean window
```

### `METRICS_ONLY` — safe rollout

Shadow mode is the key to introducing a breaker without risk: it records the
exact failure and slow-call rates real traffic produces, so you can tune
thresholds against live data before letting the breaker reject anything. It
costs almost nothing to leave on.

## Coordinated state (optional)

With a shared [storage](../integrations/redis.md), `OPEN` and `HALF_OPEN` can
also be *adopted* from other instances: a trip anywhere in the fleet gates
admission everywhere, and the HALF_OPEN probe budget is shared globally.
`breaker.state` then reports the effective state — the shared one when it
governs admission, the local one otherwise (including while the storage is
unreachable).

## Observing transitions

Every transition (and reset) is delivered to the breaker's
[`EventListener`](observability.md), so you can log or export state changes
without polling `breaker.state`.

---

<!-- source: docs/guides/failure-classification.md -->

# Failure classification

What counts as a failure is a separate concern from *when to trip* (thresholds,
in [Config](configuration.md)). It is decided by a `FailureClassifier`.

## Default policy

By default, a call is a failure exactly when it **raises**, and any returned
value is a success:

```python
from interlock import CircuitBreaker

breaker = CircuitBreaker(name='svc')   # DefaultFailureClassifier
```

This is right for code that signals errors by raising. It is *not* enough when
failure is encoded in a **return value** — for example an HTTP response object
whose `503` status means the dependency is unhealthy.

## Classify by result

A classifier implements one method. The `result`/`exception` pair is mutually
exclusive: when `exception` is not `None` the call raised; otherwise `result`
holds the return value.

```python
from interlock import CircuitBreaker

class StatusClassifier:
    def is_failure(self, *, result: object, exception: BaseException | None) -> bool:
        if exception is not None:
            return True
        return getattr(result, 'status_code', 200) >= 500

breaker = CircuitBreaker(name='api', classifier=StatusClassifier())
result = breaker.call(client.get, url)   # a 503 response now counts as a failure
```

Result-based classification needs the return value, so it works with the
**decorator** and **`call`**, but not the context manager (which only sees
exceptions and duration).

## Ignore expected errors

Business errors — a `404`, a validation failure — should not open the circuit.
Encode that by treating only the exceptions you care about as failures:

```python
class IgnoreNotFound:
    def is_failure(self, *, result: object, exception: BaseException | None) -> bool:
        if isinstance(exception, NotFoundError):
            return False          # expected, not a dependency problem
        return exception is not None
```

## HTTP out of the box

For httpx2, you do not need to write this yourself — the
[httpx2 integration](../integrations/httpx2.md) ships `HttpStatusClassifier`,
which treats transport exceptions and the canonical retryable statuses
(`429, 500, 502, 503, 504`) as failures.

---

<!-- source: docs/guides/observability.md -->

# Observability

A breaker reports everything it does through an `EventListener`. The same
hooks back logging, metrics, and any custom sink.

## The hooks

```python
class EventListener(Protocol):
    def on_state_change(self, *, name: str, old: State, new: State) -> None: ...
    def on_call(self, *, name: str, outcome: Outcome, duration: float) -> None: ...
    def on_rejected(self, *, name: str) -> None: ...
    def on_reset(self, *, name: str) -> None: ...
    def on_storage_degraded(self, *, name: str, error: BaseException) -> None: ...
    def on_storage_recovered(self, *, name: str) -> None: ...
```

Listeners are called **outside** the breaker's lock, after the protected call
returns, so a slow listener never serialises throughput. Implementations must
not raise back into the core.

The two storage hooks fire only for breakers coordinated through a shared
[storage](../integrations/redis.md), and the engine dispatches them only if
present — a listener without them keeps working.

Attach one per breaker, or share one across a `Registry`:

```python
breaker = CircuitBreaker(name='payments', listener=my_listener)
registry = Registry(listener=my_listener)   # every breaker reports here
```

## Logging (zero dependencies)

`LoggingEventListener` is built in. State changes and rejections log at
`WARNING`, resets at `INFO`, and individual calls at `DEBUG`:

```python
from interlock import CircuitBreaker, LoggingEventListener

breaker = CircuitBreaker(name='payments', listener=LoggingEventListener())
```

Pass your own logger to control routing:

```python
import logging

LoggingEventListener(logging.getLogger('myapp.breakers'))
```

## OpenTelemetry metrics

The OTel listener lives in the `interlock-cb[otel]` extra and is imported
explicitly, so the core stays dependency-free:

```bash
uv add 'interlock-cb[otel]'
```

```python
from interlock import CircuitBreaker
from interlock.integrations.otel import OTelEventListener

breaker = CircuitBreaker(name='payments', listener=OTelEventListener())
```

It records five instruments on the `interlock` meter (or a meter you pass in):

| Instrument | Type | Labels |
|------------|------|--------|
| `interlock.call.duration` | histogram (s) | `breaker`, `outcome` |
| `interlock.call.rejected` | counter | `breaker` |
| `interlock.state.changes` | counter | `breaker`, `from`, `to` |
| `interlock.reset` | counter | `breaker` |
| `interlock.storage.events` | counter | `breaker`, `event` (`degraded`/`recovered`), `error` |

## Custom listeners

Any object with the four core methods satisfies the protocol — no base class to
inherit (the two storage hooks are dispatched only if present). The core calls
all four, so define each one, leaving the hooks you do not need as no-ops:

```python
class RejectionCounter:
    def __init__(self) -> None:
        self.rejected = 0

    def on_rejected(self, *, name: str) -> None:
        self.rejected += 1

    def on_state_change(self, *, name, old, new) -> None: ...
    def on_call(self, *, name, outcome, duration) -> None: ...
    def on_reset(self, *, name) -> None: ...
```

---

<!-- source: docs/guides/timeout.md -->

# Timeout

A circuit breaker without a timeout is unsafe. A call that hangs forever is
never counted as slow or failed — it just holds a resource indefinitely.
`timeout` bounds an awaited block and turns a hang into a `CallTimeoutError`,
which a surrounding breaker records as a (slow) failure.

```python
from interlock import timeout

async with timeout(2.0):
    await client.get(url)        # raises CallTimeoutError after 2 seconds
```

## Composing with a breaker

Compose `timeout` with a breaker manually — pipeline composition is a v2
feature. Put the timeout *inside* the protected callable so the breaker observes
the `CallTimeoutError`:

```python
from interlock import CircuitBreaker, timeout

breaker = CircuitBreaker(name='search')

@breaker
async def search(q: str) -> bytes:
    async with timeout(2.0):
        return await client.get('/search', params={'q': q})
```

Now a request that exceeds 2 seconds raises `CallTimeoutError`; the breaker
counts it as a failure and, once the failure rate crosses the threshold, opens
the circuit — converting slow hangs into fast rejections.

## Synchronous code

`timeout` relies on asyncio cancelling the coroutine in place, which has no
synchronous equivalent: a blocking call cannot be interrupted from outside its
own thread, and `signal.SIGALRM` only works in the main thread, so it breaks in
threaded servers. `sync_timeout` instead runs the callable in a daemon worker
thread and joins it with a deadline. It is a decorator, so it wraps a *callable*
rather than a block:

```python
from interlock import CircuitBreaker, sync_timeout

breaker = CircuitBreaker(name='search')

@breaker
@sync_timeout(2.0)
def search(q: str) -> bytes:
    return client.get('/search', params={'q': q}).content
```

A call that exceeds 2 seconds raises `CallTimeoutError`, which the breaker
records exactly as with the async path. The decorator preserves the wrapped
function's signature, arguments and return value.

!!! warning "The worker keeps running after a timeout"
    Python cannot forcibly kill a thread. After `sync_timeout` raises, the
    worker thread keeps running in the background until the call returns on its
    own — it cannot be cancelled, so it may still hold the resource it was
    waiting on. The caller is unblocked immediately, but the underlying work is
    not stopped. Prefer the async `timeout` wherever you control an event loop;
    reach for `sync_timeout` only in genuinely synchronous code.

## Why not bake it in?

interlock keeps retry, fallback and timeout as explicit, observable features
rather than hidden magic inside the breaker. You decide the deadline at the call
site, and the failure it produces flows through the same classification and
metrics as any other.

---

<!-- source: docs/guides/retries.md -->

# Retries and circuit breakers

Retries and circuit breakers pull in opposite directions: a retry *adds*
load to a struggling dependency, a breaker *sheds* it. Combined carelessly
they cancel each other out — retries hammer a dependency the breaker is
trying to protect, or the breaker's window never sees the real failure rate.
This guide fixes the composition; the ready-made tenacity helpers live in the
[tenacity integration](../integrations/tenacity.md).

## Which goes on the outside?

Both orders are valid — they answer different questions. What changes is what
the breaker's sliding window *sees*:

| Order | What the window sees | When to choose |
|---|---|---|
| **Retry outside → breaker inside** (recommended) | Every attempt individually — honest failure rate, the breaker trips as early as the dependency deserves | Default. Also the order used by Polly and resilience4j |
| Breaker outside → retry inside | One aggregated outcome per *operation* (all attempts folded into it) | When thresholds are tuned per business operation, not per request |

With retry outside, a rejected attempt is also visible to the retry loop —
which is exactly where the two failure modes below come from.

## Failure mode 1: retrying an open circuit

`CircuitOpenError` is not a transient error. The breaker rejects instantly,
so an exponential backoff loop around it burns its attempt budget in
milliseconds, never reaches the dependency, and buries the real signal in log
noise. Stop retrying the moment the circuit opens:

```python
from tenacity import Retrying, stop_after_attempt, wait_exponential_jitter

from interlock.integrations.tenacity import retry_unless_open

retrying = Retrying(
    retry=retry_unless_open(TimeoutError, ConnectionError),
    wait=wait_exponential_jitter(),
    stop=stop_after_attempt(5),
    reraise=True,
)
```

## Failure mode 2: blind waiting

Sometimes waiting *is* the right call — a nightly job would rather sleep
than fail. But `2^n` seconds is the wrong amount: the breaker already knows
when it will allow the next probe (`CircuitOpenError.retry_after`). Wait
exactly that long:

```python
from tenacity import AsyncRetrying, retry_if_exception_type, stop_after_attempt
from tenacity import wait_exponential_jitter

from interlock import CircuitOpenError
from interlock.integrations.tenacity import wait_probe

retrying = AsyncRetrying(
    retry=retry_if_exception_type((TimeoutError, CircuitOpenError)),
    wait=wait_probe(wait_exponential_jitter()),
    stop=stop_after_attempt(10),
    reraise=True,
)
```

Pick one mode per call site. Fail fast at request/latency-sensitive
boundaries; be patient in background work.

## Retrying on HTTP statuses

The HTTP integrations classify statuses for the *breaker* without raising —
a `503` response is returned to you, recorded as a failure. tenacity,
however, is exception-driven. Do **not** reach for `retry_if_result`: with
aiohttp a retried-away response is never released and leaks its connection.
Turn bad statuses into exceptions instead, then retry exceptions:

```python
import requests
from tenacity import Retrying, stop_after_attempt, wait_exponential_jitter

from interlock.integrations.requests import CircuitBreakerAdapter
from interlock.integrations.tenacity import retry_unless_open

session = requests.Session()
session.mount('https://', CircuitBreakerAdapter())


def fetch_orders() -> dict:
    response = session.get('https://api.example.com/orders')
    response.raise_for_status()
    return response.json()


retrying = Retrying(
    retry=retry_unless_open(requests.HTTPError, requests.ConnectionError),
    wait=wait_exponential_jitter(),
    stop=stop_after_attempt(5),
    reraise=True,
)

orders = retrying(fetch_orders)
```

The breaker still classifies by status (no exception needed), the retry loop
reacts to `raise_for_status()` — each tool sees the signal in its native
form. To align which statuses trip the breaker, pass
`HttpStatusClassifier(failure_statuses={...})` to the integration.

## Anti-patterns

- **Unbounded retries.** Always set a `stop` condition. A breaker caps
  concurrent damage, not the lifetime of a stubborn loop.
- **Retries without a breaker.** N clients × M retries is an N·M-fold
  amplification aimed at a dependency that is already failing — the classic
  retry storm. The breaker inside the loop is what breaks it.
- **Retrying non-transient errors.** A `404` or a validation error will not
  succeed on attempt five. List transient exception types explicitly in
  `retry_unless_open(...)` rather than retrying everything.
- **Nested retry layers.** urllib3's `max_retries`, your service mesh and
  tenacity each multiply attempts. Budget them together — one deliberate
  retry layer beats three accidental ones.

---

<!-- source: docs/integrations/index.md -->

# Integrations

interlock plugs into the HTTP client, framework or retry library you already
use — you configure thresholds once and the breaker applies **per host** (or
per named dependency) with no decorators in call sites.

## Supported integrations

| Integration | Extra | What you get |
|---|---|---|
| [httpx2](httpx2.md) | `interlock-cb[httpx2]` | `CircuitBreakerTransport` / `AsyncCircuitBreakerTransport` — per-host breaker at the transport level |
| [aiohttp](aiohttp.md) | `interlock-cb[aiohttp]` | `CircuitBreakerMiddleware` — per-host breaker as a client middleware (aiohttp ≥ 3.12) |
| [requests](requests.md) | `interlock-cb[requests]` | `CircuitBreakerAdapter` — per-host breaker mounted on a `Session` |
| [tenacity](tenacity.md) | `interlock-cb[tenacity]` | Retry × breaker glue: stop retrying when the circuit opens, or wait exactly until the next probe |
| [FastAPI](fastapi.md) | `interlock-cb[fastapi]` | `Depends`-injected breakers and a `CircuitOpenError → 503 + Retry-After` handler |
| [Redis](redis.md) | `interlock-cb[redis]` | Shared breaker state across processes with graceful degradation |
| [LLM SDKs](llm.md) | — (recipe) | Guard OpenAI / Anthropic SDK calls with a breaker + bounded retries |
| [Flask / Django](frameworks.md) | — (recipe) | Map `CircuitOpenError` to `503 + Retry-After` in other web frameworks |

## How integrations are built

Every integration follows the same rules, so learning one means knowing all:

- **Native extension points only.** A transport (httpx2), a client middleware
  (aiohttp), an adapter (requests), an exception handler (FastAPI). No
  monkey-patching, no private APIs — an integration survives minor releases
  of its host library.
- **One breaker per host.** HTTP integrations key breakers by request host:
  a failing `api.a` never trips `api.b`. Breakers are created lazily in a
  shared [`Registry`](../reference.md).
- **One classification model.** Responses are classified by an
  `HttpStatusClassifier` — by default the canonical retryable set
  (`429, 500, 502, 503, 504`) plus any transport exception counts as a
  failure, while `4xx` client mistakes do not. Pass
  `HttpStatusClassifier(failure_statuses={...})` or your own
  `FailureClassifier` to change the policy.
- **One rejection signal.** An open circuit always raises
  [`CircuitOpenError`](../reference.md) — carrying the breaker name, a
  `retry_after` estimate and the last recorded failure — *before* a
  connection is attempted.
- **Zero-dependency core.** Integrations live in `interlock.integrations.*`
  as optional extras; `import interlock` itself never pulls anything beyond
  the standard library.

## Support tiers

- **Tier 1 — shipped code.** Modules under `interlock.integrations.*`,
  covered by the test suite and CI against both the minimum supported and the
  latest version of the host library. Semver applies.
- **Tier 2 — recipes.** Documented, runnable patterns (LLM SDKs,
  Flask/Django) that need no dedicated glue code. They can graduate to Tier 1
  when demand shows up.

Missing an integration — gRPC, SQLAlchemy, Kafka, Celery?
[Open an issue](https://github.com/bagowix/interlock/issues): the next wave
is prioritised by demand.

---

<!-- source: docs/integrations/httpx2.md -->

# httpx2 integration

The `interlock-cb[httpx2]` extra wraps an [httpx2](https://pypi.org/project/httpx2/)
transport so a circuit breaker is applied **per host** transparently — no
decorators or `call` wrappers in your request code.

=== "uv"

    ```bash
    uv add 'interlock-cb[httpx2]'
    ```

=== "pip"

    ```bash
    pip install 'interlock-cb[httpx2]'
    ```

=== "poetry"

    ```bash
    poetry add 'interlock-cb[httpx2]'
    ```

## Synchronous client

```python
import httpx2
from interlock.integrations.httpx2 import CircuitBreakerTransport

transport = CircuitBreakerTransport(httpx2.HTTPTransport())
client = httpx2.Client(transport=transport)

response = client.get('https://api.example.com/v1/users')
```

## Asynchronous client

```python
import httpx2
from interlock.integrations.httpx2 import AsyncCircuitBreakerTransport

transport = AsyncCircuitBreakerTransport(httpx2.AsyncHTTPTransport())
client = httpx2.AsyncClient(transport=transport)

response = await client.get('https://api.example.com/v1/users')
```

## Per-host isolation

Each host gets its own breaker, created lazily and cached. A failing
`api.a.example.com` trips only its own breaker; requests to
`api.b.example.com` are unaffected. Per-instance, per-host state is usually more
correct than global state — each host's health is observed independently.

When a host's breaker is open, its requests raise `CircuitOpenError` before
reaching the network.

## What counts as a failure

By default the transport uses `HttpStatusClassifier`:

- any transport exception (connect/read errors) → failure;
- a response with status `429, 500, 502, 503, 504` → failure;
- everything else, including `4xx` client errors like `404`, → success.

This mirrors the retryable set used by urllib3, AWS and Google clients.
Permanent `5xx` (`501`, `505`) are deliberately excluded — retrying or tripping
the breaker cannot fix a contract or protocol error.

## Tuning

Pass any of `config`, `clock`, `classifier`, `listener` to the transport; they
flow to every per-host breaker:

```python
from interlock import Config, LoggingEventListener
from interlock.integrations.httpx2 import CircuitBreakerTransport

transport = CircuitBreakerTransport(
    httpx2.HTTPTransport(),
    config=Config(failure_rate_threshold=0.25, minimum_number_of_calls=50),
    listener=LoggingEventListener(),
)
```

Supply your own `classifier` to change the failure policy — for example to also
fail on `408 Request Timeout`.

---

<!-- source: docs/integrations/aiohttp.md -->

# aiohttp integration

The `interlock-cb[aiohttp]` extra guards every request a `ClientSession`
sends with a circuit breaker **per host**, wired in as a client middleware —
no decorators in call sites. Requires aiohttp ≥ 3.12 (client middlewares).

=== "uv"

    ```bash
    uv add 'interlock-cb[aiohttp]'
    ```

=== "pip"

    ```bash
    pip install 'interlock-cb[aiohttp]'
    ```

=== "poetry"

    ```bash
    poetry add 'interlock-cb[aiohttp]'
    ```

## Usage

```python
import aiohttp

from interlock.integrations.aiohttp import CircuitBreakerMiddleware

middleware = CircuitBreakerMiddleware()

async with aiohttp.ClientSession(middlewares=(middleware,)) as session:
    async with session.get('https://api.example.com/orders') as response:
        orders = await response.json()
```

Each host gets its own breaker (a failing `api.a` never trips `api.b`),
created lazily and shared across requests. When a host's circuit is open the
request raises [`CircuitOpenError`](../reference.md) *before* a connection is
made.

The breaker observes the time to *response headers*; reading the body happens
outside the guarded call — the same semantics as the
[httpx2 transport](httpx2.md).

## Failure policy

By default a response counts as a failure when its status is in the canonical
retryable set (`429, 500, 502, 503, 504`) and any exception raised while
sending (connect/read errors) is a failure; `4xx` client mistakes like `404`
are successes. Change the statuses, or the whole policy:

```python
from interlock import Config
from interlock.integrations.aiohttp import CircuitBreakerMiddleware, HttpStatusClassifier

middleware = CircuitBreakerMiddleware(
    config=Config(failure_rate_threshold=0.3),
    classifier=HttpStatusClassifier(failure_statuses={408, 429, 500, 502, 503, 504}),
)
```

Any custom `FailureClassifier` works too — see
[Failure classification](../guides/failure-classification.md).

## Tuning and observability

The middleware accepts the same collaborators as `CircuitBreaker` — `config`,
`clock`, `classifier`, `listener`. One middleware instance holds one registry
of per-host breakers; reuse the instance across sessions to share breaker
state, or create separate instances to isolate them. For application-level
retries combine with the [tenacity integration](tenacity.md) and read
[Retries and circuit breakers](../guides/retries.md) first.

---

<!-- source: docs/integrations/requests.md -->

# requests integration

The `interlock-cb[requests]` extra guards every request a `Session` sends
with a circuit breaker **per host** — mounted once, no decorators in call
sites.

=== "uv"

    ```bash
    uv add 'interlock-cb[requests]'
    ```

=== "pip"

    ```bash
    pip install 'interlock-cb[requests]'
    ```

=== "poetry"

    ```bash
    poetry add 'interlock-cb[requests]'
    ```

## Usage

`CircuitBreakerAdapter` subclasses `requests.adapters.HTTPAdapter` — the
library's native transport extension point — so it mounts like any adapter:

```python
import requests

from interlock.integrations.requests import CircuitBreakerAdapter

session = requests.Session()
adapter = CircuitBreakerAdapter()
session.mount('https://', adapter)
session.mount('http://', adapter)

response = session.get('https://api.example.com/orders')
```

Each host gets its own breaker (a failing `api.a` never trips `api.b`),
created lazily and shared across requests. When a host's circuit is open the
request raises [`CircuitOpenError`](../reference.md) *before* a connection is
made.

## Failure policy

By default a response counts as a failure when its status is in the canonical
retryable set (`429, 500, 502, 503, 504`) and any transport exception
(connect/read errors) is a failure; `4xx` client mistakes like `404` are
successes. Change the statuses, or the whole policy:

```python
from interlock import Config
from interlock.integrations.requests import CircuitBreakerAdapter, HttpStatusClassifier

adapter = CircuitBreakerAdapter(
    config=Config(failure_rate_threshold=0.3),
    classifier=HttpStatusClassifier(failure_statuses={408, 429, 500, 502, 503, 504}),
)
```

Any custom `FailureClassifier` works too — see
[Failure classification](../guides/failure-classification.md).

## Tuning and observability

The adapter accepts the same collaborators as `CircuitBreaker` — `config`,
`clock`, `classifier`, `listener` — and forwards everything else
(`pool_connections`, `max_retries`, ...) to `HTTPAdapter`. Note that
`max_retries` is urllib3's connection-level retry; for application-level
retries combine with the [tenacity integration](tenacity.md) and read
[Retries and circuit breakers](../guides/retries.md) first.

---

<!-- source: docs/integrations/tenacity.md -->

# tenacity integration (retries)

interlock deliberately ships no retry engine of its own:
[tenacity](https://tenacity.readthedocs.io/) already does backoff, jitter,
stop conditions and predicates well. The `interlock-cb[tenacity]` extra adds
the glue where retry × breaker composition goes wrong in practice.

=== "uv"

    ```bash
    uv add 'interlock-cb[tenacity]'
    ```

=== "pip"

    ```bash
    pip install 'interlock-cb[tenacity]'
    ```

=== "poetry"

    ```bash
    poetry add 'interlock-cb[tenacity]'
    ```

Read [Retries and circuit breakers](../guides/retries.md) first if you are
deciding *how* to combine the two patterns; this page documents the helpers.

## Fail fast (recommended default)

`retry_unless_open(*transient)` retries the listed transient exceptions but
stops as soon as the breaker opens. `CircuitOpenError` is not transient: the
breaker rejects instantly, so backing off and retrying it only burns the
attempt budget without ever reaching the dependency.

```python
from tenacity import Retrying, stop_after_attempt, wait_exponential_jitter

from interlock import CircuitBreaker
from interlock.integrations.tenacity import retry_unless_open

breaker = CircuitBreaker(name='payments')


@breaker
def charge(amount: int) -> str:
    return gateway.charge(amount)


retrying = Retrying(
    retry=retry_unless_open(TimeoutError, ConnectionError),
    wait=wait_exponential_jitter(),
    stop=stop_after_attempt(5),
    reraise=True,
)

result = retrying(charge, 100)
```

Called without arguments, `retry_unless_open()` retries any ordinary
`Exception` — still never `CircuitOpenError`.

## Patient mode (wait for the probe)

Background jobs often prefer waiting over failing. `wait_probe(fallback)` is
a wait strategy: when the last attempt was rejected with a `retry_after`
estimate, it sleeps *exactly* until the breaker allows the next probe (plus a
small jitter so concurrent waiters do not storm the single probe slot). Any
other outcome delegates to the `fallback` strategy.

```python
from tenacity import (
    AsyncRetrying,
    retry_if_exception_type,
    stop_after_attempt,
    wait_exponential_jitter,
)

from interlock import CircuitOpenError
from interlock.integrations.tenacity import wait_probe

retrying = AsyncRetrying(
    retry=retry_if_exception_type((TimeoutError, CircuitOpenError)),
    wait=wait_probe(wait_exponential_jitter()),
    stop=stop_after_attempt(10),
    reraise=True,
)

report = await retrying(nightly_export)
```

Note the retry predicate: patient mode deliberately *does* retry
`CircuitOpenError` — that is what makes `wait_probe` see the rejection and
wait the right amount. Keep a `stop` condition anyway; a dependency can stay
down longer than any job should wait.

`wait_probe(..., jitter=0.5)` widens the random extra wait (seconds) added on
top of `retry_after`; when the rejection carries no estimate (for example
after `force_open()`), the fallback strategy decides.

## Everything else is plain tenacity

`Retrying`, `AsyncRetrying`, the `@retry` decorator, stop and wait strategies
compose as usual — the helpers are ordinary tenacity predicates and wait
objects, so you can combine them with `|`, `retry_any`, `wait_chain` and
friends.

---

<!-- source: docs/integrations/fastapi.md -->

# FastAPI integration

The `interlock-cb[fastapi]` extra protects a route's outgoing dependency with a
shared `Registry` and turns a tripped breaker into a clean
`503 Service Unavailable` response with a `Retry-After` header.

=== "uv"

    ```bash
    uv add 'interlock-cb[fastapi]'
    ```

=== "pip"

    ```bash
    pip install 'interlock-cb[fastapi]'
    ```

=== "poetry"

    ```bash
    poetry add 'interlock-cb[fastapi]'
    ```

## Usage

Install the exception handler once, then inject a per-name breaker into any route
with `Depends`:

```python
from typing import Annotated

from fastapi import Depends, FastAPI
from interlock import CircuitBreaker, Registry
from interlock.integrations.fastapi import breaker_dependency, install_exception_handler

app = FastAPI()
registry = Registry()
install_exception_handler(app)

orders_db = breaker_dependency('orders-db', registry=registry)


@app.get('/orders')
async def orders(breaker: Annotated[CircuitBreaker, Depends(orders_db)]) -> list[dict]:
    return await breaker.call(fetch_orders)
```

When `fetch_orders` fails often enough, the breaker opens. The next request is
rejected with `CircuitOpenError` *before* `fetch_orders` runs, and the installed
handler converts it into:

```http
HTTP/1.1 503 Service Unavailable
Retry-After: 30
Content-Type: application/json

{"detail": "Circuit 'orders-db' is open"}
```

## How it works

- **`breaker_dependency(name, *, registry)`** returns a FastAPI dependency that
  yields the named breaker from the shared `Registry`. The breaker is created
  lazily on first use and reused on every later request, so all requests to that
  route share one breaker (and one view of the dependency's health).
- **`install_exception_handler(app)`** registers a handler for
  `CircuitOpenError`. It responds `503` and sets `Retry-After` to the breaker's
  `retry_after` estimate, rounded up to whole seconds (per RFC 7231). The header
  is omitted when there is no estimate (for example after `force_open()`).

You protect the *outgoing* call (`breaker.call(...)`) rather than the route
itself: only the dependency you wrap counts toward the breaker, and the breaker's
own admission logic (probes, half-open) keeps working.

## Sharing breakers across routes

Reuse the same `name` (and the same `registry`) to share one breaker across
several routes that all depend on the same downstream:

```python
orders_db = breaker_dependency('orders-db', registry=registry)


@app.get('/orders')
async def list_orders(breaker: Annotated[CircuitBreaker, Depends(orders_db)]) -> list[dict]:
    return await breaker.call(fetch_orders)


@app.get('/orders/{order_id}')
async def get_order(
    order_id: int, breaker: Annotated[CircuitBreaker, Depends(orders_db)]
) -> dict:
    return await breaker.call(fetch_order, order_id)
```

Pass `config`, `clock`, `classifier` or `listener` to the `Registry` to tune
every breaker it creates, or override per name via `registry.get(name, config=...)`.

## Custom responses

For a different response shape, register your own handler instead of
`install_exception_handler`:

```python
from fastapi import Request, Response
from interlock import CircuitOpenError


@app.exception_handler(CircuitOpenError)
async def on_open(request: Request, exc: CircuitOpenError) -> Response:
    ...
```

---

<!-- source: docs/integrations/redis.md -->

# Redis integration (shared state)

The `interlock-cb[redis]` extra coordinates breaker state across processes and
machines through Redis: when one instance trips, every instance backs off, and
recovery probes are budgeted globally instead of per process.

=== "uv"

    ```bash
    uv add 'interlock-cb[redis]'
    ```

=== "pip"

    ```bash
    pip install 'interlock-cb[redis]'
    ```

=== "poetry"

    ```bash
    poetry add 'interlock-cb[redis]'
    ```

## When to share state — and when not to

Per-instance state is the default for a reason. A local breaker reacts only to
what *this* process observes, cannot be affected by another instance's problem,
and keeps working when Redis does not.

Reach for shared state when all of these hold:

- Many instances call the **same downstream**, and its failure affects all of
  them equally (a shared database, a rate-limited third-party API).
- You want **coordinated back-off**: once the downstream is declared unhealthy,
  no instance should keep hammering it just because its own window has not
  filled yet.
- You want **bounded recovery probing**: N instances should send at most
  `permitted_calls_in_half_open` probes *in total*, not each.

Stay local when instances see genuinely different views of the dependency
(per-AZ endpoints, canary deployments), or when one instance's network problems
must not silence the whole fleet. A shared OPEN gates traffic *everywhere* —
that is the point, and the risk. It is a trade-off you opt into, not a default.

## Usage

Pass a storage to the breaker (or to a `Registry`, which hands it to every
breaker it creates — each coordinates under its own name):

```python
import redis
from interlock import CircuitBreaker, Registry
from interlock.integrations.redis import RedisStorage

storage = RedisStorage(redis.Redis(host='redis.internal'))
breaker = CircuitBreaker(name='payments', storage=storage)

registry = Registry(storage=storage)  # or share one storage across many breakers
```

Async services use the async client and storage:

```python
import redis.asyncio
from interlock import CircuitBreaker
from interlock.integrations.redis import AsyncRedisStorage

storage = AsyncRedisStorage(redis.asyncio.Redis(host='redis.internal'))
breaker = CircuitBreaker(name='payments', storage=storage)
```

A coordinated breaker matches its storage's runtime: a `RedisStorage` serves
only the sync API (`with`, sync `call`), an `AsyncRedisStorage` only the async
one (`async with`, async `call`); mixing the styles raises `InterlockError`
with a clear message. A breaker *without* a storage stays fully dual.

## How coordination works

The local state machine keeps owning the sliding window and trip detection;
Redis owns the shared OPEN/HALF_OPEN state and the global probe budget. All
state for one breaker lives in a single hash (`interlock:cb:<name>` by
default), and every transition runs as a Lua script, so racing instances stay
consistent.

The protected path stays fast:

- **CLOSED / OPEN admission** reads a locally cached view of the shared state —
  zero inline Redis calls. A background poller refreshes the cache every
  `poll_interval` seconds, so a trip on one instance reaches the others within
  roughly one interval.
- **HALF_OPEN admission** is the single inline Redis operation: an atomic probe
  lease that decrements the shared budget, bounding probes across the fleet.
- **Writes** (propagating a local trip, tallying probe outcomes, the final
  close-or-reopen decision) are fire-and-forget on a background worker; they
  never block a protected call.

Time comparisons ("has `wait_duration_in_open` elapsed?") use the *Redis
server's* clock, since instance clocks are not comparable. After the last probe
of a round, the deciding instance applies the same thresholds as the local
state machine and writes the transition guarded by a version check, so a
delayed decision can never overwrite a newer state.

## Degradation: Redis down ≠ breaker down

A storage error never reaches your calls. On the first failure the breaker
switches to its local state and keeps protecting the process on its own window;
pending shared writes are dropped, and Redis is left alone for `retry_backoff`
seconds before the poller tries again. On the first successful operation the
shared view becomes authoritative again — including adopting a shared OPEN that
happened while this instance was cut off.

Both edges are observable through the listener:

```python
class StorageWatch:
    def on_storage_degraded(self, *, name: str, error: BaseException) -> None:
        ...  # alert: running on local state

    def on_storage_recovered(self, *, name: str) -> None:
        ...  # back to coordinated state
```

`LoggingEventListener` logs degradation at `WARNING` and recovery at `INFO`;
`OTelEventListener` counts both on `interlock.storage.events`. Listeners
written before these hooks existed keep working — the engine calls them only if
present.

## Tuning

All knobs live on the storage constructor; the core `Config` stays
storage-agnostic:

```python
RedisStorage(
    client,
    key_prefix='interlock:cb:',  # hash key namespace
    state_ttl=300.0,             # key lifetime (s); refreshed on every write
    poll_interval=1.0,           # cache refresh cadence (s)
    retry_backoff=5.0,           # local-only time after a storage failure (s)
)
```

- **`state_ttl`** keeps abandoned state from lingering: if every instance
  disappears, the key expires and the breaker starts CLOSED. Keep it well above
  `wait_duration_in_open`.
- **`poll_interval`** is the propagation latency of a coordinated trip. Each
  breaker costs about one Redis read per interval.
- **`retry_backoff`** bounds how often a degraded breaker re-tests Redis.

## Compatibility

`RedisStorage` speaks plain commands and `EVAL` — no server-specific features —
so it works against Redis, [Valkey](https://valkey.io), or any RESP-compatible
server. The scripts call `TIME` before writing, which requires effect-based
script replication: **Redis 5.0 or newer**, or any Valkey release. (The
`redis>=5.0.0` dependency pin is the *client* library's version, not the
server's.)

---

<!-- source: docs/integrations/llm.md -->

# LLM SDKs (OpenAI, Anthropic) — recipe

LLM APIs fail in exactly the ways circuit breakers exist for: rate limits
(`429`), overloaded backends (`529`/`503`), long hangs. A breaker around your
LLM calls stops a degraded provider from stalling every request thread, and
bounded retries recover from blips without amplifying an outage.

This is a **recipe** — no extra needed beyond `interlock-cb[tenacity]`; the
SDKs raise typed exceptions, which is all the breaker needs.

## Classify SDK errors

Both SDKs raise `APIStatusError` subclasses carrying `status_code`, plus
connection/timeout errors. Not every error should trip the circuit: an
invalid request (`400`) or a missing model (`404`) is your bug, not the
provider's outage.

```python
import anthropic


class LLMFailureClassifier:
    """Trip on provider-side trouble, not on caller mistakes."""

    _FAILURE_STATUSES = frozenset({429, 500, 502, 503, 504, 529})

    def is_failure(self, *, result: object, exception: BaseException | None) -> bool:
        if exception is None:
            return False
        if isinstance(exception, anthropic.APIStatusError):
            return exception.status_code in self._FAILURE_STATUSES
        return isinstance(exception, (anthropic.APIConnectionError, anthropic.APITimeoutError))
```

For OpenAI, swap the exception types (`openai.APIStatusError`,
`openai.APIConnectionError`, `openai.APITimeoutError`) — the shape is
identical.

## Guard the calls

```python
import anthropic
from tenacity import AsyncRetrying, stop_after_attempt, wait_exponential_jitter

from interlock import CircuitBreaker, Config
from interlock.integrations.tenacity import retry_unless_open

client = anthropic.AsyncAnthropic()

breaker = CircuitBreaker(
    name='anthropic',
    config=Config(slow_call_duration_threshold=30.0),
    classifier=LLMFailureClassifier(),
)


@breaker
async def complete(prompt: str) -> str:
    message = await client.messages.create(
        model='claude-sonnet-5',
        max_tokens=1024,
        messages=[{'role': 'user', 'content': prompt}],
    )
    return message.content[0].text


retrying = AsyncRetrying(
    retry=retry_unless_open(
        anthropic.APIStatusError,
        anthropic.APIConnectionError,
        anthropic.APITimeoutError,
    ),
    wait=wait_exponential_jitter(initial=1.0, max=30.0),
    stop=stop_after_attempt(4),
    reraise=True,
)

answer = await retrying(complete, 'Summarise this document...')
```

What each layer contributes:

- **Slow-call detection** (`slow_call_duration_threshold`) counts calls
  slower than 30s as failures — a provider that still answers but takes a
  minute per completion trips the breaker too. No other signal catches this.
- **The breaker** stops sending after the failure rate crosses the threshold;
  while open, callers get `CircuitOpenError` in microseconds instead of
  hanging — fail over to a second provider or degrade gracefully.
- **`retry_unless_open`** retries provider blips with jittered backoff but
  stops the moment the circuit opens. The SDK's own retries overlap here —
  either set `max_retries=0` on the client and let tenacity own retries, or
  keep the SDK's and drop the tenacity layer; running both multiplies
  attempts.

## Multiple providers, one pattern

Give each provider its own breaker name (`anthropic`, `openai`, ...) via a
shared `Registry` and check `breaker.state` to route around an open provider.
The [states guide](../guides/states.md) covers manual failover controls.

!!! note "Transport-level alternative"
    The SDKs are built on classic httpx, which does not take httpx2
    transports; once they migrate to httpx2 you will be able to drop the
    decorator entirely and pass a client wrapped with the
    [httpx2 transport](httpx2.md) instead.

---

<!-- source: docs/integrations/frameworks.md -->

# Flask / Django — recipe

When a route's outgoing dependency trips its breaker, the raised
[`CircuitOpenError`](../reference.md) should become a clean
`503 Service Unavailable` with a `Retry-After` header — the same behaviour
the [FastAPI extra](fastapi.md) ships as code. For other frameworks the
handler is a few lines; no extra needed.

## Flask

```python
import math

from flask import Flask, jsonify

from interlock import CircuitOpenError

app = Flask(__name__)


@app.errorhandler(CircuitOpenError)
def on_circuit_open(exc: CircuitOpenError):
    response = jsonify({'detail': str(exc)})
    response.status_code = 503
    if exc.retry_after is not None:
        response.headers['Retry-After'] = str(math.ceil(exc.retry_after))
    return response
```

## Django

```python
# middleware.py
import json
import math

from django.http import HttpResponse

from interlock import CircuitOpenError


class CircuitOpenMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        return self.get_response(request)

    def process_exception(self, request, exception):
        if not isinstance(exception, CircuitOpenError):
            return None
        response = HttpResponse(
            json.dumps({'detail': str(exception)}),
            status=503,
            content_type='application/json',
        )
        if exception.retry_after is not None:
            response['Retry-After'] = str(math.ceil(exception.retry_after))
        return response
```

Add it to `MIDDLEWARE` in `settings.py`.

## Where the breakers live

The handler only translates the rejection. The breakers themselves guard your
*outgoing* calls — share one `Registry` across the app and wrap the
dependencies:

```python
from interlock import Registry

registry = Registry()
payments = registry.get('payments-api')


def charge(amount: int) -> str:
    return payments.call(gateway.charge, amount)
```

`Retry-After` is rounded up to whole seconds (per RFC 7231) and omitted when
the breaker cannot estimate the next probe (for example after
`force_open()`).

---

<!-- source: docs/reference.md -->

# API reference

Everything below is importable from the top-level `interlock` package, except
the integration adapters, which live in their own modules to keep the core
dependency-free.

## `CircuitBreaker`

```python
CircuitBreaker(*, name, config=None, clock=None, classifier=None, listener=None, storage=None)
```

A named breaker for sync and async callables.

- **Use as** a decorator (`@breaker`), a sync/async context manager
  (`with` / `async with`), or `breaker.call(fn, *args, **kwargs)`.
- **Properties:** `name: str`, `state: State`.
- **`snapshot() -> WindowSnapshot`** — current window aggregates.
- **Manual control:** `reset()`, `force_open()`, `disable()`, `metrics_only()`.
- **`storage`** — optional shared backend (`Storage` or `AsyncStorage`) for
  coordinated state across instances; see the
  [Redis integration](integrations/redis.md). A coordinated breaker matches its
  storage's runtime (sync storage → sync API, async storage → async API);
  without a storage the breaker stays fully dual.

## `Config`

Frozen dataclass of thresholds, window and timing; validated on construction.
See [Configuration](guides/configuration.md) for every field. Raises
`ValueError` on invalid input.

## `Registry`

```python
Registry(*, config=None, clock=None, classifier=None, listener=None, storage=None)
registry.get(name, *, config=None) -> CircuitBreaker
```

Creates and caches named breakers. The same name always returns the same
instance; the per-call `config` override applies only at creation. A `storage`
is handed to every breaker the registry creates; each coordinates under its own
name.

## Enums

- **`State`** — `CLOSED`, `OPEN`, `HALF_OPEN`, `FORCED_OPEN`, `DISABLED`,
  `METRICS_ONLY`. A `StrEnum`; values are stable lowercase identifiers.
- **`Outcome`** — `SUCCESS`, `FAILURE`, `SLOW_SUCCESS`, `SLOW_FAILURE`, with
  `.is_failure` and `.is_slow` properties.
- **`WindowType`** — `COUNT_BASED`, `TIME_BASED`.

## `WindowSnapshot`

Frozen dataclass: `total_calls`, `failed_calls`, `slow_calls`, plus
`.failure_rate` and `.slow_call_rate` properties (both `0.0` when empty).

## Errors & warnings

- **`InterlockError`** — base of all interlock errors.
- **`CircuitOpenError(breaker_name, *, retry_after=None, last_failure=None)`** —
  raised on rejection; attributes `breaker_name`, `retry_after`, `last_failure`.
- **`CallTimeoutError(timeout)`** — raised by `timeout` and `sync_timeout`;
  attribute `timeout`.
- **`InterlockDeprecationWarning`** — subclasses `UserWarning`, visible by
  default.

## `timeout` / `sync_timeout`

```python
async with timeout(seconds): ...   # async block

@sync_timeout(seconds)             # synchronous callable
def work(): ...
```

`timeout` is an async context manager that raises `CallTimeoutError` if the
block exceeds `seconds`. `sync_timeout` is a decorator that runs a synchronous
callable in a daemon worker thread and raises `CallTimeoutError` if it overruns
`seconds`; the worker keeps running after a timeout (Python cannot kill a
thread). See [Timeout](guides/timeout.md).

## Protocols (extension points)

Implement any of these to swap a core behaviour:

- **`Clock`** — `monotonic() -> float`. Inject a fake for deterministic tests.
- **`SlidingWindow`** — `record(outcome)`, `snapshot() -> WindowSnapshot`.
- **`Storage`** / **`AsyncStorage`** — shared-state backend as atomic *intent*
  operations: `read`, `trip_open`, `begin_half_open_if_elapsed`, `lease_probe`,
  `record_probe`, `close`. `trip_open`/`close` take an optional
  `expected_version` (version-fenced CAS); every write carries a `ttl`.
  Mechanism only — threshold policy stays in the core. `AsyncStorage` is the
  awaitable mirror. See the [Redis integration](integrations/redis.md).
- **`FailureClassifier`** — `is_failure(*, result, exception) -> bool`. See
  [Failure classification](guides/failure-classification.md).
- **`EventListener`** — `on_state_change`, `on_call`, `on_rejected`, `on_reset`,
  plus `on_storage_degraded` / `on_storage_recovered` for coordinated breakers
  (dispatched only if present, so pre-1.2 listeners keep working). See
  [Observability](guides/observability.md).

## Shared-state types

- **`SharedState`** — frozen snapshot of one breaker's coordinated state:
  `state`, `opened_at` (backend time), `version` (for fencing), and the
  HALF_OPEN probe accounting (`probes_permitted`, `probes_remaining`,
  `probes_completed`, `probe_failures`, `probe_slows`).
  `SharedState.closed()` is the baseline an absent key implies.
- **`ProbeLease`** — result of `lease_probe`: `granted: bool` plus the
  post-attempt `state: SharedState`.

## Listeners

- **`LoggingEventListener(logger=None)`** — top-level; zero dependencies.
- **`interlock.integrations.otel.OTelEventListener(meter=None)`** — extra `interlock-cb[otel]`.

## httpx2 adapters

Extra `interlock-cb[httpx2]`, module `interlock.integrations.httpx2`:

- **`CircuitBreakerTransport(transport, *, config=None, clock=None, classifier=None, listener=None)`**
- **`AsyncCircuitBreakerTransport(transport, *, ...)`**
- **`HttpStatusClassifier(failure_statuses=None)`** — fails on transport
  exceptions and statuses `429, 500, 502, 503, 504` (override the set via
  `failure_statuses`).

See the [httpx2 integration](integrations/httpx2.md).

## aiohttp adapters

Extra `interlock-cb[aiohttp]` (aiohttp ≥ 3.12), module `interlock.integrations.aiohttp`:

- **`CircuitBreakerMiddleware(*, config=None, clock=None, classifier=None, listener=None)`** —
  client middleware for `ClientSession(middlewares=(...,))`; one breaker per
  request host.
- **`HttpStatusClassifier(failure_statuses=None)`** — same policy as the
  httpx2 variant, reading `ClientResponse.status`.

See the [aiohttp integration](integrations/aiohttp.md).

## requests adapters

Extra `interlock-cb[requests]`, module `interlock.integrations.requests`:

- **`CircuitBreakerAdapter(*, config=None, clock=None, classifier=None, listener=None, **adapter_kwargs)`** —
  `HTTPAdapter` subclass for `session.mount(...)`; one breaker per request
  host. Extra kwargs go to `HTTPAdapter`.
- **`HttpStatusClassifier(failure_statuses=None)`** — same policy, reading
  `Response.status_code`.

See the [requests integration](integrations/requests.md).

## tenacity helpers

Extra `interlock-cb[tenacity]`, module `interlock.integrations.tenacity`:

- **`retry_unless_open(*transient)`** — tenacity retry predicate: retries the
  listed transient exception types (default: any `Exception`), never
  `CircuitOpenError`.
- **`wait_probe(fallback, *, jitter=0.1)`** — tenacity wait strategy: sleeps
  `CircuitOpenError.retry_after` (+ up to `jitter` seconds) after a
  rejection, delegates to `fallback` otherwise.

See the [tenacity integration](integrations/tenacity.md) and the
[retries guide](guides/retries.md).

## FastAPI adapters

Extra `interlock-cb[fastapi]`, module `interlock.integrations.fastapi`:

- **`breaker_dependency(name, *, registry)`** — returns a `Depends`-compatible
  callable yielding the named breaker from a shared `Registry`.
- **`install_exception_handler(app)`** — registers a handler mapping
  `CircuitOpenError` to `503` with a `Retry-After` header.
- **`circuit_open_handler(request, exc)`** — the handler itself, for custom
  registration.

See the [FastAPI integration](integrations/fastapi.md).

## Redis adapters

Extra `interlock-cb[redis]`, module `interlock.integrations.redis`:

- **`RedisStorage(client, *, key_prefix='interlock:cb:', state_ttl=300.0, poll_interval=1.0, retry_backoff=5.0)`** —
  sync `Storage` over a `redis.Redis` client.
- **`AsyncRedisStorage(client, *, ...)`** — async mirror over
  `redis.asyncio.Redis`.

One Redis hash per breaker; every transition is a Lua script (atomic across
racing instances), elapse checks use the server's `TIME`. Works against Redis
(5.0+), Valkey, or any RESP-compatible server.

See the [Redis integration](integrations/redis.md).
