Skip to content

Runnable demo

Three self-contained scripts in examples/ show a breaker doing its job — standard library plus interlock-cb only, no network, no services to stand up. The output is deterministic: what you see below is exactly what you get, so set a breakpoint anywhere and step through.

pip install interlock-cb          # or: uv add interlock-cb

python examples/lifecycle.py      # one breaker through its full state cycle
python examples/two_clients.py    # two clients, one outage, no collateral damage
python examples/pipeline.py       # timeout + breaker + fallback around a hanging service

Both scripts print through an EventListener — every line tagged [listener] comes from the breaker itself, not from the demo code. See Observability for the hook reference.

lifecycle.py — one breaker, full cycle

A fake payment gateway is healthy, goes down, and recovers. The breaker is configured tightly so the whole story fits in seven calls: trip at 50% failures over a 4-call window, stay open 1 second, close after 2 good probes.

lifecycle.py — full source
"""Walk one breaker through its full lifecycle: CLOSED -> OPEN -> HALF_OPEN -> CLOSED.

Zero dependencies, no network — run it directly and watch every transition:

    python examples/lifecycle.py

The gateway is deterministic (healthy, then down, then recovered), so the
output is always the same. Tweak the ``Config`` values and re-run to see how
the thresholds change the story. Explained line by line in
https://bagowix.github.io/interlock/demo/.
"""

import time

from interlock import CircuitBreaker, CircuitOpenError, Config, Outcome, State


class GatewayError(Exception):
    """The fake dependency's failure mode."""


class FlakyGateway:
    """A payment gateway you can switch between healthy and down."""

    def __init__(self) -> None:
        self.healthy = True

    def charge(self, amount: int) -> str:
        if not self.healthy:
            raise GatewayError('503 from gateway')

        return f'charged ${amount}'


class PrintListener:
    """An EventListener that narrates everything the breaker does."""

    def on_state_change(self, *, name: str, old: State, new: State) -> None:
        print(f'  [listener] {name}: state {old.name} -> {new.name}')

    def on_call(self, *, name: str, outcome: Outcome, duration: float) -> None:
        print(f'  [listener] {name}: recorded {outcome.name} ({duration:.3f}s)')

    def on_rejected(self, *, name: str) -> None:
        print(f'  [listener] {name}: call rejected — circuit is open')

    def on_reset(self, *, name: str) -> None:
        print(f'  [listener] {name}: manual reset')


gateway = FlakyGateway()
breaker = CircuitBreaker(
    name='payments-api',
    config=Config(
        failure_rate_threshold=0.5,  # trip at >= 50% failures ...
        minimum_number_of_calls=4,  # ... once the window holds 4 calls
        window_size=4,
        wait_duration_in_open=1.0,  # stay OPEN for 1s, then allow probes
        permitted_calls_in_half_open=2,  # 2 good probes close the circuit
    ),
    listener=PrintListener(),
)


@breaker
def charge(amount: int) -> str:
    """The protected call: the decorator preserves this signature."""
    return gateway.charge(amount)


def attempt(number: int, amount: int) -> None:
    """Make one charge attempt and print what came back."""
    try:
        print(f'call {number}: {charge(amount)}')
    except GatewayError as exc:
        print(f'call {number}: dependency failed — {exc}')
    except CircuitOpenError as exc:
        print(
            f'call {number}: REJECTED in ~0ms, the gateway was never called '
            f'(retry_after={exc.retry_after:.1f}s)'
        )


def main() -> None:
    """Run the four phases of the lifecycle."""
    print('phase 1 — healthy dependency, breaker CLOSED')
    attempt(1, 10)
    attempt(2, 20)

    print()
    print('phase 2 — the gateway goes down; failures fill the window')
    gateway.healthy = False
    attempt(3, 30)
    attempt(4, 40)  # 2 failures / 4 calls = 50% -> the breaker trips

    print()
    print('phase 3 — circuit OPEN: calls fail fast, the gateway gets a break')
    attempt(5, 50)

    print()
    print('phase 4 — after wait_duration_in_open the breaker probes')
    gateway.healthy = True  # ops fixed the gateway meanwhile
    time.sleep(1.1)
    attempt(6, 60)  # probe 1 (OPEN -> HALF_OPEN on admission)
    attempt(7, 70)  # probe 2 -> both succeeded -> HALF_OPEN -> CLOSED

    print()
    print(f'final state: {breaker.state.name}, window reset: {breaker.snapshot()}')


if __name__ == '__main__':
    main()

Running it prints:

phase 1 — healthy dependency, breaker CLOSED
  [listener] payments-api: recorded SUCCESS (0.000s)
call 1: charged $10
  [listener] payments-api: recorded SUCCESS (0.000s)
call 2: charged $20

Every completed call is recorded into the sliding window — the listener's on_call hook fires with the classified Outcome and the duration.

phase 2 — the gateway goes down; failures fill the window
  [listener] payments-api: recorded FAILURE (0.000s)
call 3: dependency failed — 503 from gateway
  [listener] payments-api: recorded FAILURE (0.000s)
  [listener] payments-api: state CLOSED -> OPEN
call 4: dependency failed — 503 from gateway

Call 4 is the moment the window reaches minimum_number_of_calls=4 with 2 failures out of 4 — exactly the 50% threshold — so recording it trips the circuit: on_state_change fires inside call 4's bookkeeping, before the demo's own call 4: line prints.

phase 3 — circuit OPEN: calls fail fast, the gateway gets a break
  [listener] payments-api: call rejected — circuit is open
call 5: REJECTED in ~0ms, the gateway was never called (retry_after=1.0s)

Call 5 never reaches the gateway. CircuitOpenError is raised immediately and carries retry_after — the estimate until the next probe is allowed. This is the entire point of a breaker: while the dependency is down, callers spend no timeouts on it and it gets quiet time to recover.

phase 4 — after wait_duration_in_open the breaker probes
  [listener] payments-api: state OPEN -> HALF_OPEN
  [listener] payments-api: recorded SUCCESS (0.000s)
call 6: charged $60
  [listener] payments-api: recorded SUCCESS (0.000s)
  [listener] payments-api: state HALF_OPEN -> CLOSED
call 7: charged $70

final state: CLOSED, window reset: WindowSnapshot(total_calls=0, failed_calls=0, slow_calls=0)

The transition to HALF_OPEN is lazy: it happens when call 6 asks for admission after the 1-second wait, not on a timer (set Config.auto_transition=True for the eager variant). Calls 6 and 7 are the two permitted probes; both succeed, so recording the second one closes the circuit and starts a fresh window.

Things to try: set gateway.healthy = False before phase 4 and watch the failed probe reopen the circuit; raise permitted_calls_in_half_open; put a breakpoint in PrintListener.on_state_change and inspect breaker.snapshot() at each transition.

two_clients.py — isolation under a partial outage

One asyncio app talks to two dependencies. A Registry hands each its own breaker, so when recommendations goes down during rounds 3–6, only its circuit opens — the app serves a cached fallback — while payments keeps charging as if nothing happened.

two_clients.py — full source
"""Two guarded clients in one event loop: one dependency dies, the other keeps serving.

Zero dependencies, no network — run it directly:

    python examples/two_clients.py

A ``Registry`` hands out one independent breaker per dependency. The
``recommendations`` service goes down during rounds 3-6: its breaker opens
and the app falls back to a cached list, while ``payments`` — its own
breaker untouched — keeps charging without a hiccup. Explained line by line
in https://bagowix.github.io/interlock/demo/.
"""

import asyncio

from interlock import CircuitOpenError, Config, Outcome, Registry, State


class RecsDownError(Exception):
    """The recommendations service's failure mode."""


class PrintListener:
    """An EventListener that narrates state changes and rejections."""

    def on_state_change(self, *, name: str, old: State, new: State) -> None:
        print(f'  [listener] {name}: state {old.name} -> {new.name}')

    def on_call(self, *, name: str, outcome: Outcome, duration: float) -> None:
        pass  # per-call noise is off; see examples/lifecycle.py for it

    def on_rejected(self, *, name: str) -> None:
        print(f'  [listener] {name}: call rejected — circuit is open')

    def on_reset(self, *, name: str) -> None:
        print(f'  [listener] {name}: manual reset')


registry = Registry(
    config=Config(
        failure_rate_threshold=0.5,
        minimum_number_of_calls=4,
        window_size=4,
        wait_duration_in_open=1.0,
        permitted_calls_in_half_open=2,
    ),
    listener=PrintListener(),
)

outage = False


@registry.get('recommendations')
async def fetch_recommendations(user: str) -> list[str]:
    """Call the flaky recommendations service."""
    if outage:
        raise RecsDownError('recommendations service timed out')
    return [f'{user}-pick-1', f'{user}-pick-2']


@registry.get('payments')
async def charge(user: str, amount: int) -> str:
    """Call the payments service, which stays healthy throughout."""
    return f'charged {user} ${amount}'


async def round_trip(number: int, user: str) -> None:
    """One request round: hit both dependencies concurrently."""
    print(f'round {number}:')
    payment, recs = await asyncio.gather(
        charge(user, 25),
        fetch_recommendations(user),
        return_exceptions=True,
    )
    print(f'  payments        -> {payment}')
    if isinstance(recs, CircuitOpenError):
        print('  recommendations -> rejected instantly -> fallback: cached picks')
    elif isinstance(recs, RecsDownError):
        print(f'  recommendations -> failed ({recs}) -> fallback: cached picks')
    else:
        print(f'  recommendations -> {recs}')


async def main() -> None:
    """Run eight rounds across the outage and the recovery."""
    global outage  # noqa: PLW0603 - a module-level switch keeps the demo flat

    print('rounds 1-2 — both dependencies healthy')
    await round_trip(1, 'alice')
    await round_trip(2, 'bob')

    print()
    print('rounds 3-6 — recommendations goes down; payments must not care')
    outage = True
    await round_trip(3, 'carol')
    await round_trip(4, 'dave')  # 50% failures -> recommendations breaker opens
    await round_trip(5, 'erin')  # rejected in ~0ms: no timeout is burned
    await round_trip(6, 'frank')

    print()
    print('rounds 7-8 — the outage is over; the breaker probes and closes')
    outage = False
    await asyncio.sleep(1.1)  # let wait_duration_in_open elapse
    await round_trip(7, 'grace')
    await round_trip(8, 'heidi')

    print()
    for name in ('payments', 'recommendations'):
        print(f'final state of {name}: {registry.get(name).state.name}')


if __name__ == '__main__':
    asyncio.run(main())

The interesting part of the output:

round 4:
  [listener] recommendations: state CLOSED -> OPEN
  payments        -> charged dave $25
  recommendations -> failed (recommendations service timed out) -> fallback: cached picks
round 5:
  [listener] recommendations: call rejected — circuit is open
  payments        -> charged erin $25
  recommendations -> rejected instantly -> fallback: cached picks

Round 4's failure is the second in a 4-call window — the recommendations breaker trips. From round 5 on the difference matters: the round-4 request waited for the dependency to fail, the round-5 request is rejected in microseconds, so the user still gets their page (with cached picks) at full speed. Notice what is absent: the payments breaker never logs a single state change for the whole run.

rounds 7-8 — the outage is over; the breaker probes and closes
round 7:
  [listener] recommendations: state OPEN -> HALF_OPEN
  payments        -> charged grace $25
  recommendations -> ['grace-pick-1', 'grace-pick-2']
round 8:
  [listener] recommendations: state HALF_OPEN -> CLOSED
  payments        -> charged heidi $25
  recommendations -> ['heidi-pick-1', 'heidi-pick-2']

Recovery is gradual by design: two successful probes (rounds 7 and 8) must complete before the circuit closes again.

Things to try: extend the outage past round 7 and watch a failed probe send the circuit straight back to OPEN; give the two breakers different configs via registry.get(name, config=...); replace the hand-rolled fallback with a tenacity retry that waits exactly retry_after.

pipeline.py — composition against a quiet death

The nastiest failure mode gets the v2 pipeline treatment: the inventory service never raises — it hangs. On its own that trips nothing and starves every caller. Three composed strategies turn it into a non-event: a timeout makes hangs classifiable, the breaker counts them, a fallback keeps serving.

pipeline.py — full source
"""Compose timeout + breaker + fallback around one dependency that dies quietly.

Zero dependencies, no network — run it directly:

    python examples/pipeline.py

The inventory service never raises: it *hangs*. Alone, that starves callers
and trips nothing. The pipeline turns the hang into a failure chain: the
timeout cancels the attempt, the breaker counts it and opens, the fallback
serves a cached snapshot — and while the circuit is open, requests cost
~0.0s instead of a timeout each. Add `.retry(...)` between the fallback and
the breaker for the full stack (needs `interlock-cb[tenacity]`). Explained
line by line in https://bagowix.github.io/interlock/demo/.
"""

import asyncio
import time

from interlock import (
    CallTimeoutError,
    CircuitBreaker,
    CircuitOpenError,
    Config,
    Outcome,
    Pipeline,
    State,
)

CACHED_SNAPSHOT = ['widget (cached)', 'gadget (cached)']


class PrintListener:
    """Narrates the breaker's transitions and the pipeline's decisions."""

    def on_state_change(self, *, name: str, old: State, new: State) -> None:
        print(f'  [listener] {name}: state {old.name} -> {new.name}')

    def on_call(self, *, name: str, outcome: Outcome, duration: float) -> None:
        pass  # per-call noise is off; see examples/lifecycle.py for it

    def on_rejected(self, *, name: str) -> None:
        print(f'  [listener] {name}: call rejected — circuit is open')

    def on_reset(self, *, name: str) -> None:
        print(f'  [listener] {name}: manual reset')

    def on_fallback(self, *, name: str, error: BaseException) -> None:
        print(f'  [listener] {name}: fallback served instead of {type(error).__name__}')


events = PrintListener()
hanging = False
breaker = CircuitBreaker(
    name='inventory',
    config=Config(
        failure_rate_threshold=0.5,  # trip at >= 50% failures ...
        minimum_number_of_calls=4,  # ... once the window holds 4 calls
        window_size=4,
        wait_duration_in_open=1.0,  # stay OPEN for 1s, then allow probes
        permitted_calls_in_half_open=2,  # 2 good probes close the circuit
    ),
    listener=events,
)

pipeline = (
    Pipeline.builder()
    .fallback(
        lambda _exc: CACHED_SNAPSHOT,
        on=(CircuitOpenError, CallTimeoutError),
        name='inventory',
        listener=events,
    )
    .circuit_breaker(breaker)
    .timeout(0.2)  # a hanging attempt becomes CallTimeoutError after 0.2s
    .build()
)


@pipeline
async def fetch_inventory() -> list[str]:
    """The protected call: the dependency hangs instead of erroring."""
    if hanging:
        await asyncio.sleep(5)  # never finishes within the timeout
    return ['widget', 'gadget']


async def request(number: int) -> None:
    """Serve one request and print what it cost."""
    start = time.perf_counter()
    items = await fetch_inventory()
    elapsed = time.perf_counter() - start
    print(f'request {number}: {items} in ~{elapsed:.1f}s')


async def main() -> None:
    """Run the outage story end to end."""
    global hanging  # noqa: PLW0603 - a module-level switch keeps the demo flat

    print('phase 1 — healthy and fast, breaker CLOSED')
    await request(1)
    await request(2)

    print()
    print('phase 2 — the service starts hanging; timeouts become failures')
    hanging = True
    await request(3)  # waits the full 0.2s timeout, then serves the cache
    await request(4)  # 2 timeouts / 4 calls = 50% -> the breaker trips

    print()
    print('phase 3 — circuit OPEN: no timeout is burned, the cache is instant')
    await request(5)

    print()
    print('phase 4 — the service recovers; probes close the circuit')
    hanging = False
    await asyncio.sleep(1.1)  # let wait_duration_in_open elapse
    await request(6)  # probe 1 (OPEN -> HALF_OPEN on admission)
    await request(7)  # probe 2 -> both succeeded -> HALF_OPEN -> CLOSED

    print()
    print(f'final state: {breaker.state.name}')


if __name__ == '__main__':
    asyncio.run(main())

The interesting part of the output:

phase 2 — the service starts hanging; timeouts become failures
  [listener] inventory: fallback served instead of CallTimeoutError
request 3: ['widget (cached)', 'gadget (cached)'] in ~0.2s
  [listener] inventory: state CLOSED -> OPEN
  [listener] inventory: fallback served instead of CallTimeoutError
request 4: ['widget (cached)', 'gadget (cached)'] in ~0.2s

phase 3 — circuit OPEN: no timeout is burned, the cache is instant
  [listener] inventory: call rejected — circuit is open
  [listener] inventory: fallback served instead of CircuitOpenError
request 5: ['widget (cached)', 'gadget (cached)'] in ~0.0s

Watch the latency column. Requests 3–4 each pay the full 0.2 s timeout — that is the detection cost while the window fills. Request 4 tips the failure rate to 50% and the circuit opens; from request 5 on the rejection is immediate and the cached snapshot costs ~0.0 s. The user never saw an error: every response during the outage came from the fallback, and the listener logged each substitution.

phase 4 — the service recovers; probes close the circuit
  [listener] inventory: state OPEN -> HALF_OPEN
request 6: ['widget', 'gadget'] in ~0.0s
  [listener] inventory: state HALF_OPEN -> CLOSED
request 7: ['widget', 'gadget'] in ~0.0s

Things to try: raise wait_duration_in_open and watch how long the cache serves; put a .retry(...) step between the fallback and the breaker (needs interlock-cb[tenacity]) and see attempts in the listener via on_retry; drop the .timeout(...) step and watch the outage become invisible again — no strategy ever fires.

Where to go next

These demos hand-roll their guarded clients so you can debug the mechanics. In real code you usually don't have to: the integrations apply the same per-dependency pattern to httpx2, httpx, aiohttp, requests and FastAPI transparently, and the resilience pipeline composes the breaker with timeout, bulkhead, retry and fallback declaratively.