Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 30 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7 and Node.js 24.21.0
  • By MySmartCoPilot
Free core, Pro depth: 8 of 30 minutes free
Free: Three states, each an object · A breaker in front of a payment provider · When a breaker helps, and when it does not. With a pass: 3 in-depth parts and 3 interview answers, about 22 minutes.

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

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 breaker moves from closed to open on too many failures, to half-open after the break, and back on the trial result.Closedevery call goes through;outcomes go into the windowOpencalls fail at once with BrokenCircuit;no call reaches the dependencyHalf-openup to N trial calls go through;the rest fail at oncefailure ratio >= thresholdand calls >= minimum throughputthe break is over:the next call moves the statea trial succeeds:the window starts emptya trial fails:a new break starts

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.

  1. 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.
  2. 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.
  3. 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:

A circuit breaker around a payment provider that fails for 21 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

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.

  1. Question 1 of 3 What does an open circuit breaker do with a call?

    Choose one answer.

    Show the answer to question 1

    Answer: It fails the call at once, without calling the dependency

    While open, the breaker answers every call itself with an error such as BrokenCircuit, so callers stop waiting on a dependency that is known to be failing and the dependency gets time to recover.

  2. Question 2 of 3 In payments.py the provider recovered at 31 s, but the breaker kept refusing calls until 34.75 s. Why?

    Choose one answer.

    Show the answer to question 2

    Answer: The breaker learns about the recovery only through a trial call, and the next trial was due at the end of the break

    An open breaker sends nothing to the dependency, so it cannot see the recovery. The trial at 29.75 s failed, which started a new 5-second break, and the trial at 34.75 s was the first call that could find the provider healthy again. That delay is the price of failing fast.

  3. Question 3 of 3 A service calls a payment provider and a recommendation service. How many circuit breakers should it have?

    Choose one answer.

    Show the answer to question 3

    Answer: One for each dependency, so a failing recommendation service cannot block payments

    A breaker summarises the health of one dependency. A shared breaker would open for both when only one fails, turning a broken recommendation service into broken payments. Give every dependency, and often every endpoint, its own breaker.

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.

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.

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.

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

  1. 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.

  2. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

  4. 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?

    Model answer, Pro. First step and follow-ups

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.