Design Patterns (GoF and modern) Module 8 – Resilience patterns as code
Circuit breaker: fail fast, recover safely
Write a circuit breaker as a closed, open and half-open state machine with a failure ratio, a minimum throughput and a fake clock, and order it with retries.
What you will learn
- Implement a circuit breaker as a closed, open and half-open state machine
- Configure failure thresholds with a minimum throughput and a sampling window
- Combine a circuit breaker with retries in a deliberate order
- Monitor breaker state through its events and test it with a fake clock
Before you start
What you’ll learn · Before you start
What you will learn
- Implement a circuit breaker as a closed, open and half-open state machine
- Configure failure thresholds with a minimum throughput and a sampling window
- Combine a circuit breaker with retries in a deliberate order
- Monitor breaker state through its events and test it with a fake clock
Before you start
On this page
A circuit breaker sits between your code and a dependency, such as a payment provider, and watches how the calls to it end. While most of them succeed it stays closed and lets every call through. When too many recent calls have failed it opens: for a while it fails each call at once, without calling the dependency, so callers stop waiting on something that is broken and the dependency gets room to recover. When that break is over it turns half-open and lets a trial call through; a success closes it again, and a failure starts another break. Put one in front of each remote dependency whose failures cost callers time, threads or money. In code it is the State pattern applied to remote calls: closed, open and half-open are three objects, and the current one decides what happens to the next call.
The breaker and its experiments are Python, recorded with 3.14.8, and each program also starts from this page.
Three states, each an object
Martin Fowler’s article on the pattern and the Azure Circuit Breaker pattern describe the same cycle:
The three states of a circuit breaker and what moves it between them
Text description of the diagram
The diagram shows three states and four transitions.
- Closed: every call goes through, and each outcome is recorded in the sampling window. When the failure ratio reaches the threshold and the window holds at least the minimum throughput of calls, the breaker moves to open.
- Open: calls fail at once with a BrokenCircuit error, and none reaches the dependency. When the break duration is over, the next call moves the breaker to half-open; there is no timer running in the background.
- Half-open: up to N trial calls go through, and the rest fail at once. If a trial succeeds, the breaker moves back to closed with an empty window. If a trial fails, it moves back to open, and a new break starts.
In code, each state is a class with three methods: before_call(), which may refuse the call, on_success() and
on_failure(). The breaker holds its current state and passes every event to it, so the rules of one state never
mix with the rules of another. A different way to count failures is a different closed state, not another if in
every method. Five settings decide how the breaker behaves:
- The failure ratio is the share of failed calls in the window at which the breaker opens.
- The sampling window says how far back the breaker looks: a number of calls, or a span of time kept in slices.
- The minimum throughput is how many calls the window must hold before the ratio counts at all.
- The break duration is how long the breaker stays open before it lets trial calls through.
- The trial limit is how many trial calls may run at once while the breaker is half-open.
For comparison, the standard resilience handler of .NET opens at a failure ratio of 10 % over a 30-second window, once at least 100 calls are in it, and breaks for 5 seconds.
A breaker in front of a payment provider
The provider below stops answering from 10 s to 31 s, and callers try to charge a card every 250 ms. A failed call costs its caller a 1-second timeout. The breaker opens at a failure ratio of 50 %, needs at least 10 calls in its 10-second window and breaks for 5 seconds:
Python · breaker/payments.py
"""A breaker in front of a payment provider that stops answering from 10 s to 31 s.
Callers charge a card every 250 ms for 45 s of simulated time. A failed call costs its caller the 1 s timeout; a call
the breaker rejects costs nothing. The breaker opens at a failure ratio of 50 % once at least 10 calls are in its
10-second window, and stays open for 5 s before it lets one trial call through.
"""
from breaker import BrokenCircuit, CircuitBreaker
class Clock:
now_ms = 0
def charge():
if 10_000 <= Clock.now_ms < 31_000:
raise TimeoutError("no answer within 1 s")
return "charged"
def log(old, new, reason, at_ms):
print(f"{at_ms / 1000:6.2f} s {old} -> {new}: {reason}")
def run(with_breaker):
breaker = CircuitBreaker(clock=lambda: Clock.now_ms, failure_ratio=0.5, minimum_throughput=10,
sampling_ms=10_000, break_ms=5_000, on_change=log if with_breaker else None)
counts = {"charged": 0, "timed out": 0, "rejected at once": 0}
for i in range(180):
Clock.now_ms = i * 250
try:
breaker.call(charge) if with_breaker else charge()
counts["charged"] += 1
except TimeoutError:
counts["timed out"] += 1
except BrokenCircuit:
counts["rejected at once"] += 1
return counts
with_breaker = run(with_breaker=True)
without = run(with_breaker=False)
print()
print(f"{'180 calls':<18}{'without':>9}{'with breaker':>14}")
for outcome in with_breaker:
print(f"{outcome:<18}{without[outcome]:>9}{with_breaker[outcome]:>14}")
print(f"{'seconds waited':<18}{without['timed out']:>9}{with_breaker['timed out']:>14}") Output
14.75 s closed -> open: 20 of 40 calls failed in the window 19.75 s open -> half-open: the break is over 19.75 s half-open -> open: a trial call failed 24.75 s open -> half-open: the break is over 24.75 s half-open -> open: a trial call failed 29.75 s open -> half-open: the break is over 29.75 s half-open -> open: a trial call failed 34.75 s open -> half-open: the break is over 34.75 s half-open -> closed: a trial call succeeded 180 calls without with breaker charged 96 81 timed out 84 23 rejected at once 0 76 seconds waited 84 23
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 payments.py
breaker/breaker.py
"""A circuit breaker written with the State pattern: closed, open and half-open are objects that decide what happens
to a call. Time comes from a clock that is passed in (milliseconds), so simulations and tests control it.
breaker = CircuitBreaker(clock=..., failure_ratio=0.5, minimum_throughput=10, sampling_ms=10_000, break_ms=5_000)
breaker.call(charge) # runs charge(), or raises BrokenCircuit without calling it
await breaker.call_async(fetch) # the same for a coroutine function
"""
class BrokenCircuit(Exception):
"""Raised instead of calling the dependency. retry_after_ms: when the next trial may go, if the breaker knows."""
def __init__(self, state, retry_after_ms=None):
super().__init__(f"circuit {state}" + ("" if retry_after_ms is None else f", retry after {retry_after_ms} ms"))
self.retry_after_ms = retry_after_ms
class Window:
"""The outcomes of the last `sampling_ms`, in a ring of `buckets` counters that each cover an equal slice."""
def __init__(self, sampling_ms, buckets):
self.slice_ms = sampling_ms // buckets
self.ring = [[-1, 0, 0] for _ in range(buckets)] # [slice number, calls, failures]
def record(self, now_ms, failed):
n = now_ms // self.slice_ms
bucket = self.ring[n % len(self.ring)]
if bucket[0] != n: # the slot still holds an older slice: start it again
bucket[:] = [n, 0, 0]
bucket[1] += 1
bucket[2] += int(failed)
def totals(self, now_ms):
"""(calls, failures) in the slices that are still inside the window."""
n = now_ms // self.slice_ms
live = [b for b in self.ring if n - len(self.ring) < b[0] <= n]
return sum(b[1] for b in live), sum(b[2] for b in live)
def clear(self):
for bucket in self.ring:
bucket[:] = [-1, 0, 0]
class Closed:
name = "closed"
def before_call(self, cb):
pass # every call goes through
def on_success(self, cb):
cb.window.record(cb.clock(), failed=False)
def on_failure(self, cb):
now = cb.clock()
cb.window.record(now, failed=True)
calls, failures = cb.window.totals(now)
if calls >= cb.minimum_throughput and failures / calls >= cb.failure_ratio:
cb.change(Open(now + cb.break_ms), f"{failures} of {calls} calls failed in the window")
class Open:
name = "open"
def __init__(self, until_ms):
self.until_ms = until_ms
def before_call(self, cb):
now = cb.clock()
if now < self.until_ms:
raise BrokenCircuit(self.name, self.until_ms - now)
cb.change(HalfOpen(), "the break is over") # no timer: the first call after the break moves the state
cb.state.before_call(cb)
def on_success(self, cb):
pass # no call is let through while open
on_failure = on_success
class HalfOpen:
name = "half-open"
def __init__(self):
self.trials = 0
def before_call(self, cb):
if self.trials >= cb.half_open_trials:
raise BrokenCircuit(self.name) # the trial calls are taken: everyone else fails fast
self.trials += 1
def on_success(self, cb):
cb.window.clear()
cb.change(cb.closed_state(), "a trial call succeeded")
def on_failure(self, cb):
cb.change(Open(cb.clock() + cb.break_ms), "a trial call failed")
class CircuitBreaker:
def __init__(self, clock, failure_ratio, minimum_throughput, sampling_ms, break_ms, buckets=10,
half_open_trials=1, counts_as_failure=lambda error: True, on_change=None, closed_state=Closed):
self.clock = clock
self.failure_ratio = failure_ratio
self.minimum_throughput = minimum_throughput
self.sampling_ms = sampling_ms
self.break_ms = break_ms
self.half_open_trials = half_open_trials
self.counts_as_failure = counts_as_failure
self.on_change = on_change
self.window = Window(sampling_ms, buckets)
self.closed_state = closed_state # the class of the closed state: another one counts failures another way
self.state = closed_state()
def change(self, new, reason):
old, self.state = self.state, new
if self.on_change:
self.on_change(old.name, new.name, reason, self.clock())
def _admit(self):
self.state.before_call(self) # raises BrokenCircuit, or lets the call go (and may change the state)
return self.state # the state that admitted the call hears how it ended
def _ended(self, state, error):
if state is not self.state:
return # the breaker moved on while the call ran: a late outcome changes nothing
if error is None:
state.on_success(self)
elif self.counts_as_failure(error):
state.on_failure(self)
else:
state.on_success(self) # an error that says nothing about the dependency's health
def call(self, fn):
state = self._admit()
try:
result = fn()
except Exception as error:
self._ended(state, error)
raise
self._ended(state, None)
return result
async def call_async(self, fn):
state = self._admit()
try:
result = await fn()
except Exception as error:
self._ended(state, error)
raise
self._ended(state, None)
return result Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The breaker opened at 14.75 s, when 20 of the 40 calls in its window had failed. From then on it tried one call every 5 seconds, found the provider still down three times, and closed at 34.75 s when a trial succeeded. Without the breaker, 84 callers each waited a full second for a timeout; with it, 23 did, and 76 were refused at once, so their code could show an error or queue the payment straight away.
The output also shows what the breaker costs. The provider was healthy again at 31 s, but an open breaker sends nothing to the dependency, so it learned about the recovery only from its next trial: 15 calls that would have succeeded were refused. Shorter breaks find a recovery sooner and test a struggling dependency more often; that is the trade the break duration sets.
When a breaker helps, and when it does not
A breaker pays when failures are slow, such as timeouts that hold threads and connections; when a dependency fails for seconds or minutes rather than for single calls; and when callers have something useful to do with an immediate error. It adds little for brief, random errors, which a retry with backoff handles better, and nothing for a dependency called a few times a minute, which never fills the window. The Amazon Builders’ Library warns that breakers bring modes that are hard to test and can lengthen recovery, as the 15 refused calls show, so exercise the open path on purpose.
Check yourself
3 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Counting failures: windows, ratios and minimum throughput, with numbers Pro: opens with a Pro, Premium or Ultimate pass
How a breaker counts decides when it opens, how many healthy calls it refuses and whether it can open at all. This part runs four counting rules against four kinds of trouble and turns the results into settings for a real dependency.
In this part:
- A ring of time slices instead of a list of calls
- Four counting rules on four kinds of trouble, measured
- The floor that keeps a breaker from opening at all
- Choosing the numbers for one dependency
1 runnable example (Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Half-open under load: trial calls, a stampede and a stuck trial, reproduced Pro: opens with a Pro, Premium or Ultimate pass
The half-open state is where breakers fail under real traffic: callers pile up during the break and all arrive when it ends. This part reproduces a stampede of 200 waiting callers and a trial call that never answers, with simulated time.
In this part:
- What the half-open state lets through
- 200 waiting callers at the end of a break, reproduced
- A trial that never answers keeps the breaker half-open
- Late answers from calls that started earlier
2 runnable examples (Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Breaker and retry together, and watching a breaker: the order, counted Pro: opens with a Pro, Premium or Ultimate pass
A breaker almost never works alone: a retry policy sits beside it, and the order of the two decides how many calls reach a broken dependency and how long a failing request takes. This part counts four stackings, builds a dashboard from the breaker’s own numbers and ends with an exercise.
In this part:
- Four ways to stack a retry and a breaker, measured
- What a retry should do with a refusal
- One breaker for each dependency
- A dashboard and the events to alert on
- Practise: a breaker with a ratio, a floor and trial calls
- A circuit breaker with a ratio, a floor and trial calls
- Results of the sample tests
1 runnable example (Python) · 1 exercise with sample tests · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
Why does a circuit breaker need a minimum throughput as well as a failure ratio?
Because a ratio means little over a handful of calls. Without a minimum, a breaker that has seen two calls since a restart opens at 50 % when one of them fails, and then refuses healthy calls for a whole break. The minimum throughput says how many calls the window must hold before the ratio is judged at all, so one unlucky call cannot open the breaker. The minimum has a cost on the other side: if the dependency is called too rarely to reach it within the window, the breaker can never open. With a 30-second window and a minimum of 100 calls, a dependency called twice a second puts at most 60 calls in the window, so check the minimum against the quietest traffic of the day.
-
Mid to senior
A circuit breaker uses a failure ratio of 10 % over a 30-second window with a minimum of 100 calls. One night its dependency, called twice a second, was down for 20 minutes, and the breaker never opened. Explain why, and choose settings that would have opened it without making it trip on noise during the day.
-
Mid to senior
Your HTTP client has a retry policy with three attempts and a circuit breaker. In which order do you put them, and what should the retry policy do when the breaker refuses a call? Support the choice with what each order does during an outage.
-
Senior to staff
When a breaker's break ends, 200 queued requests reach a database that is still recovering, it fails again and the breaker opens for another full break. This repeats several times. What is happening, and what do you change in the breaker and around it?
Key takeaways
- A circuit breaker is closed, open or half-open, and each state is an object that decides what happens to a call.
- Open means fail at once without calling the dependency; half-open means a trial call decides whether to close.
- In the payment run, 84 one-second timeouts fell to 23, and 76 calls were refused at once.
- The price is a slower return: the breaker learns about a recovery only from its next trial.
- Give each dependency its own breaker, and use one where failures are slow and last seconds or minutes.
References
- CircuitBreaker (bliki) (Martin Fowler)
- Circuit Breaker pattern (Azure Architecture Center) (Microsoft)
- Build resilient HTTP apps: key development patterns (.NET) (Microsoft)
- Circuit breaker resilience strategy (Polly documentation) (App vNext (Polly project))
- CircuitBreaker (Resilience4j documentation) (Resilience4j project)
- Timeouts, retries, and backoff with jitter (Amazon Builders' Library) (Amazon Web Services)
- Design Patterns: Elements of Reusable Object-Oriented Software (State) (Addison-Wesley (Pearson))
- Python 3.14 documentation: asyncio tasks (wait, cancellation) (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress