A circuit breaker decides whether a new call should reach a dependency that appears unhealthy. It can reject promptly instead of spending resources on another likely failure. It does not repair that dependency, cancel calls already running, or prove that rejected work would have failed. Its value depends on the failure signals and recovery policy it actually implements.
Name the policy, not just the states
Closed means calls may proceed, not that every call will succeed. Open means new calls are refused under the breaker’s policy. Half-open admits limited trial work to gather evidence before deciding whether ordinary traffic can resume.
The detection rule is not universally a sliding-window failure percentage. Fowler’s example counts failures and resets on success; other implementations use time windows, failure ratios, slow-call thresholds, or minimum sample counts. Specify the actual rule rather than borrowing a diagram and assuming its thresholds.
The fixture uses two consecutive classified failures in completion order, a five-second cooldown, and one half-open probe. One success resets the consecutive counter. These numbers make transitions easy to verify; they are not suggested production settings.
Classify and scope failures
A malformed caller request does not necessarily mean a dependency is down. Decide which exceptions, statuses, and slow outcomes should influence the breaker. A locally refused call is also different from a request that reached the dependency and failed. Mixing those observations can make the breaker reinforce its own refusal state.
Scope state to the failure domain you intend to protect. One unavailable shard or operation need not justify blocking a healthy independent provider. Conversely, thousands of independent process-local breakers can each allow a probe, so “one half-open request” may mean one per instance, not one globally.
Timeouts remain necessary. A call that never finishes might hold a connection or worker without ever producing the failure observation needed to open the breaker. The admission decision does not replace a deadline, a concurrency limit, or resource cleanup.
Recovery is a controlled experiment
After cooldown, reserve probe admission atomically. If several threads all observe that trial work is allowed before any claims the permit, the supposed single probe becomes a burst. The example protects its small state transition with a lock; it does not hold that lock while a dependency operation runs.
An old in-flight result also needs an explicit interpretation. Here, opening creates a new local generation, and results from older permits cannot close that newer open state. The generation is bookkeeping inside this model, not a distributed fencing token or a way to undo remote work.
A failed probe starts another cooldown. A successful probe closes this fixture’s breaker, but one successful trial does not prove capacity for all returning traffic. If gradual recovery is required, validate the actual traffic-admission policy instead of assuming the word half-open guarantees a ramp.
When it breaks
Aggressive thresholds can deny healthy requests; long cooldowns can keep refusing after recovery. Overly broad scopes hide partial health. A retry loop that repeatedly catches an open-circuit refusal can waste resources even though it sends no new dependency requests.
Observe forwarded calls, classified outcomes, local refusals, state transitions, and probe results separately. Test sustained failures, occasional successes, concurrent trial admission, and late completions. A green unit test for a counter is not evidence that a live dependency recovers safely.
Check it quickly
Save the block as breaker_check.py and run python3 breaker_check.py with Python 3.10 or newer. It ran with Python 3.14.4 using only the standard library. The script models outcomes and injects monotonic-time values without sleeping or contacting a service. Eight real threads contend for a half-open permit.
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier, Lock
class OpenCircuit(Exception):
pass
class Breaker:
# Explicit fixture policy: two consecutive classified failures, five-second cooldown.
def __init__(self):
self.state, self.failures, self.until = "closed", 0, 0
self.epoch, self.probe_busy = 0, False
self.lock = Lock()
def acquire(self, now):
with self.lock:
if self.state == "open":
if now < self.until:
raise OpenCircuit
self.state = "half-open"
if self.state == "half-open":
if self.probe_busy:
raise OpenCircuit
self.probe_busy = True
return self.epoch, True
return self.epoch, False
def complete(self, permit, succeeded, now):
epoch, probe = permit
with self.lock:
if epoch != self.epoch:
return # This outcome belongs to an earlier breaker generation.
if probe:
self.probe_busy = False
self.epoch += 1
self.failures = 0
self.state = "closed" if succeeded else "open"
if not succeeded:
self.until = now + 5
elif self.state == "closed":
self.failures = 0 if succeeded else self.failures + 1
if self.failures >= 2:
self.state, self.until = "open", now + 5
self.epoch += 1
breaker = Breaker()
other_dependency = Breaker()
forwarded = []
def invoke(succeeded, now):
permit = breaker.acquire(now)
forwarded.append(now) # A modeled dependency invocation, not a network call.
breaker.complete(permit, succeeded, now)
invoke(False, 0)
assert breaker.failures == 1
invoke(True, 1)
assert breaker.failures == 0
late = breaker.acquire(1)
invoke(False, 2)
invoke(False, 3)
assert breaker.state == "open" and breaker.until == 8
other_permit = other_dependency.acquire(4)
other_dependency.complete(other_permit, True, 4)
assert other_dependency.state == "closed"
before = len(forwarded)
try:
invoke(True, 7.99)
except OpenCircuit:
pass
else:
raise AssertionError("open circuit must reject before dispatch")
assert len(forwarded) == before
breaker.complete(late, True, 7.99)
assert breaker.state == "open"
print("success resets consecutive failures; two later failures open until time 8")
print("open calls are not dispatched; an older in-flight success does not close a newer open generation")
gate = Barrier(8)
def probe_attempt(_):
gate.wait(timeout=5)
try:
return breaker.acquire(8)
except OpenCircuit:
return None
with ThreadPoolExecutor(max_workers=8) as pool:
permits = list(pool.map(probe_attempt, range(8)))
admitted = [permit for permit in permits if permit is not None]
assert len(admitted) == 1 and admitted[0][1] is True
assert breaker.state == "half-open" and breaker.probe_busy
breaker.complete(admitted[0], False, 8)
assert breaker.state == "open" and breaker.until == 13
print("eight simultaneous half-open candidates receive one probe permit and seven refusals")
print("failed probe opens a new cooldown ending at time 13")
try:
breaker.acquire(12.99)
except OpenCircuit:
pass
else:
raise AssertionError("second cooldown must still apply")
probe = breaker.acquire(13)
breaker.complete(probe, True, 13)
assert breaker.state == "closed" and not breaker.probe_busy
invoke(True, 14)
assert other_dependency.state == "closed" and other_dependency.failures == 0
print("a successful later probe closes this breaker; another dependency has separate state")
An initial success resets the failure count. Two later failures open the breaker until time eight, and an attempted call before that boundary adds no modeled dispatch. A previously admitted success cannot close the newer generation. A second dependency’s already existing breaker remains usable.
At time eight, eight simultaneous candidates obtain one permit and seven refusals. Failure of that probe opens another interval ending at thirteen. A later successful probe closes the breaker and permits ordinary work again.
Each granted permit in this model receives one completion with an already classified outcome. Real transport timeouts, cancellation, automatic failure classification, distributed probe coordination, library integration, and recovery throughput are not tested. Use an established implementation where appropriate and verify its precise contracts against the real workload.
Further reading
Sources
- Martin Fowler: Circuit breaker, failure counting, success reset, and trial recovery, checked 2026-09-08.
- Azure architecture: Circuit breaker, state semantics, exception classification, concurrency, and failure-domain scope, checked 2026-09-08.
- Python threading, locks and barriers used by the concurrent permit experiment, checked 2026-09-08.