# 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.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/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.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.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.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/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.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.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.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/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.otel.OTelEventListener(meter=None)`** — extra `interlock-cb[otel]`.

## httpx2 adapters

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

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

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

## FastAPI adapters

Extra `interlock-cb[fastapi]`, module `interlock.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.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).
