States & manual control¶
A breaker has three core states plus three operator overrides.
Core lifecycle¶
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: probes succeed
HALF_OPEN --> OPEN: a probe fails
CLOSED— traffic flows; outcomes are recorded. When the failure rate (or slow-call rate) crosses its threshold over at leastminimum_number_of_calls, the breaker trips toOPEN.OPEN— calls are rejected immediately withCircuitOpenError. Afterwait_duration_in_openseconds, the next call lazily moves the breaker toHALF_OPEN. Enableauto_transitionto have a timer make that move on its own.HALF_OPEN— a limited number of 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. Probes succeeding closes the breaker; a probe failing re-opens it.
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:
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. |
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.
Observing transitions¶
Every transition (and reset) is delivered to the breaker's
EventListener, so you can log or export state changes
without polling breaker.state.