cld-toys › Toys › circuit-breaker

Commentary: circuit-breaker

One breaker, one trace, two dependencies. The half-open probe is a measurement taken at a load of one, acted on at a load of everything — and only one of the two dependencies makes that inference safe. A study guide for circuit_breaker.py.

circuit-breaker/ on GitHub·the source with line numbers, for reading rather than downloading
How to read this This is the only documentation the toy has — read it with circuit_breaker.py open beside you. circuit_breaker.py is the toy itself (200 lines: one breaker, two dependencies, one simulation loop); demo.py drives both dependencies with one trace; test_circuit_breaker.py locks in the trace and the arithmetic derived from it (12 tests). Every transcript below was captured from a real run on macOS (Darwin 25.5.0, arm64), Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3], stdlib only.
cd circuit-breaker
python3 demo.py                  # the aha (§6)
python3 test_circuit_breaker.py  # pins the trace this page describes
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

A circuit breaker watches the calls you make to a dependency, and when too many of them fail it stops making them: closed (calls pass), open (calls are rejected without being attempted), half-open (one call is let through to see whether the dependency is back). That much is a familiar diagram, and if the toy stopped there it would only be teaching you to read a state chart.

The thing worth building it for is the half-open probe, and what it assumes. The probe is a single call. The breaker takes that one call's outcome as a verdict on the whole dependency and, if it succeeds, immediately resumes sending everything. That inference — from one call at essentially zero load, to the health of a dependency about to receive a herd — is only valid for a dependency whose health does not depend on load.

So this toy runs one breaker, with one configuration, against one arrival trace, and swaps out only the dependency:

Both start failing at exactly tick 10. The breaker cannot tell them apart, because the only measurement it ever takes is a single call. Against the first, that probe is a valid sample and the breaker is free money. Against the second, every probe succeeds, is contradicted by the very next tick, and the breaker converts a ten-tick surge into an outage that never ends.

By the end you should be able to:


2. The problem this mechanism exists to solve

You call a service over a network. It stops working. What should your code do?

The naive answer — keep calling, let each call fail — is genuinely bad, and for a reason that is easy to underrate. A remote call that fails fast costs you almost nothing. A remote call that hangs costs you a thread, a connection, a slot in a pool, and a multi-second timeout, for every call, for as long as the dependency is unwell. Michael Nygard's original framing in Release It! is that this is how a single sick dependency takes down the system in front of it: the callers run out of threads waiting on it, and then they fail for requests that never needed the sick dependency at all. The breaker's first job is to keep a dead dependency from consuming its callers.

There is a second job, which is where the trouble starts: protecting the dependency. A service that is falling over is often falling over because of the traffic it is receiving, and the argument goes that removing the traffic gives it room to recover.

The distinction this toy is built to force Those two jobs sound like one job. Protecting the caller is about not wasting the caller's resources on calls that will fail — valid whenever the calls really will fail. Protecting the dependency is about reducing the load it receives — a load-management goal, for which a circuit breaker is a spectacularly coarse instrument: it has two settings, all and nothing.

A dependency failing for its own reasons — a bad deploy, a dead node, an expired certificate — is serving zero requests successfully. Sending it nothing costs nothing. A dependency failing because of load is, by definition, still serving as much as it can. Sending it nothing throws that away.

And the breaker has to decide which situation it is in using the only instrument it has: the outcome of calls it made. When it is open it is making no calls, so it has no information at all — which is why it must eventually guess, let one call through, and generalise from it.

The competing goals that make more than one design defensible:


3. Background you need

None of this is deep, but the commentary below leans on it.

ConceptWhere it's used hereOne source
Closed / open / half-open The state machine in CircuitBreaker; allow owns one of the four transitions, record the other three Martin Fowler: CircuitBreaker
Rolling error rate over a request-volume floor recorddeque(maxlen=window) plus min_calls, copied from Hystrix's errorThresholdPercentage and requestVolumeThreshold Hystrix: How it Works
Duty cycle §6.4 — the fraction of ticks the breaker spends closed, which is the dependency's effective throughput Duty cycle
Congestion collapse §6.4, §8 — offered load rising while goodput falls, the failure mode the breaker produces here Network congestion § Congestive collapse
Retry amplification / thundering herd simulate — shed requests come back next tick, so they pile into a herd behind the open breaker AWS: Timeouts, retries, and backoff with jitter
Injected time allow(now) / record(ok, now) — integer ticks supplied by the caller, no clock anywhere time.monotonic

The two that carry the result are duty cycle and congestion collapse. Everything in §6.4 is one arithmetic step away from the first, and the second is the name for what that arithmetic produces. If you only internalise one row, take the duty cycle: a breaker that is open four ticks in six has cut its dependency's throughput by two thirds, and it did that on purpose.


4. The mental model

Before any code. The state machine first, since it is the part you already half-know:

error rate >= threshold ┌────────┐ (over >= min_calls) ┌────────┐ │ CLOSED │ ─────────────────────> │ OPEN │ │ │ │ │ │ all │ <────┐ │ none │ │ calls │ │ │ get │ │ pass │ │ │ through│ └────────┘ │ └────────┘ │ │ probe │ │ `cooldown` ticks succeeds│ │ elapse │ ┌───────────┐ │ └───│ HALF-OPEN │<─────┘ │ `probes` │ │ calls/tick│──────┐ └───────────┘ │ probe fails ▲ │ └────────────┘

Now the part that is actually the toy. Put the same breaker in front of two dependencies that both start failing at tick 10:

DEAD — health is a function of the CLOCK. health │████████░░░░░░░░░░████████████████ (░ = failing) └────────┬─────────┬──────────────> t 10 20 a 1-call probe at t=20 says "healthy". 40 calls/tick at t=21 also say "healthy". The sample GENERALISES. One call was enough. OVERLOADED — health is a function of the OFFERED LOAD. ok/tick │ ╱──────────── capacity = 20 │ ╱ │ ╱ └──────────────────> offered load 20 409 a 1-call probe says "healthy" (1 <= 20 ✓) 409 calls the very next tick say "20 ok, 389 failed" The sample DOES NOT GENERALISE. It was taken at a load that stopped existing the instant the breaker trusted it.
The slogan to carry A circuit breaker measures a dependency at a load of one and then acts at a load of everything. That is a safe inference exactly when health does not depend on load, and an unsafe one exactly when it does — which is the case the breaker was sold to you to handle.

5. Reading the source

The file is 200 lines. Read it in this order.

5.1 The two dependencies — six lines that are the entire experiment

circuit_breaker.py · lines 99–111
class DeadDependency:
    """Fails every call between tick `down` and tick `up`, and succeeds
    every call outside that interval. `k` is ignored: how much load it is
    offered has no bearing on whether it works.
    """

    def __init__(self, down, up):
        self.down, self.up = down, up

    def call(self, now, k):
        if self.down <= now < self.up:
            return 0, k
        return k, 0
circuit_breaker.py · lines 114–125
class OverloadedDependency:
    """Serves `capacity` calls per tick and rejects the rest. `now` is
    ignored: the offered load `k` is the only thing that decides whether a
    call fails. It is never "down" — at k <= capacity it is perfect.
    """

    def __init__(self, capacity):
        self.capacity = capacity

    def call(self, now, k):
        ok = min(k, self.capacity)
        return ok, k - ok

Start here, because everything else in the toy is machinery and these are the claim. Read the two call bodies as a pair:

They are deliberately built as each other's complement. Each takes exactly one of the two arguments seriously, so "why did this fail?" has a single-word answer in each case, and the two answers are the only two answers there are. Anything you can measure about the rate of failures is identical between them at tick 10 — both go from perfect to badly broken — so no amount of cleverness in the breaker's trip logic can distinguish them. The only thing that separates them is a question the breaker never asks: does load change the answer?

OverloadedDependency is deliberately the kindest possible overloaded service. It degrades gracefully: it serves its full 20 and fast-rejects the overflow, with no queueing, no thrashing, no slow-down under load, and it recovers the instant the load drops. A real overloaded service is worse than this in every direction. The toy uses the flattering version so that when the breaker makes things worse, you cannot blame the dependency's own dynamics.

5.2 CircuitBreaker.__init__ — the knobs, and where they came from

circuit_breaker.py · lines 32–43
    def __init__(self, window=20, min_calls=10, error_rate=0.5, cooldown=5,
                 probes=1):
        self.window = window
        self.min_calls = min_calls
        self.error_rate = error_rate
        self.cooldown = cooldown
        self.probes = probes
        self.state = "closed"
        self.outcomes = deque(maxlen=window)
        self.opened_at = None
        self.probe_tick = None
        self.probes_used = 0

These are not invented. Each maps one-to-one onto a knob in a breaker people actually run:

this toyHystrixresilience4j
windowmetrics.rollingStats.timeInMillisecondsslidingWindowSize
min_callscircuitBreakerRequestVolumeThresholdminimumNumberOfCalls
error_ratecircuitBreakerErrorThresholdPercentagefailureRateThreshold (default 50)
cooldowncircuitBreakerSleepWindowInMillisecondswaitDurationInOpenState
probes(not configurable — always 1)permittedNumberOfCallsInHalfOpenState (default 3)

That table is worth more than it looks, because §7.1 and §7.2 are about which of these rows matter. Hystrix's documentation describes the half-open state as letting "the next single request" through, with no knob at all; resilience4j exposes one and defaults it to 3. §7.2 measures what those choices are worth here.

self.outcomes = deque(maxlen=window) is the whole memory of the breaker when closed: a bounded ring of booleans. maxlen does the eviction, so there is no explicit trimming anywhere — appending the 21st outcome silently drops the 1st.

5.3 allow — the only place open → half_open happens

circuit_breaker.py · lines 45–63
    def allow(self, now):
        """Gate one call. The only place open -> half_open happens."""
        if self.state == "open":
            if now - self.opened_at < self.cooldown:
                return False
            self.state = "half_open"
            self.probe_tick = now
            self.probes_used = 0

        if self.state == "half_open":
            if now != self.probe_tick:      # a fresh tick gets a fresh budget
                self.probe_tick = now
                self.probes_used = 0
            if self.probes_used >= self.probes:
                return False
            self.probes_used += 1
            return True

        return True

Three decisions here are worth slowing down for.

The cooldown expiry is checked lazily, on a call. There is no timer and no background task; open → half_open happens inside a request, the first time one arrives after the cooldown has elapsed. This is the same lazy-refill trick a token bucket uses, and it has the same payoff: a breaker guarding a dependency nobody is calling costs nothing, and there is no clock to skew. It also has a consequence people trip over in production: a breaker for an endpoint with no traffic never leaves the open state, because leaving it requires a request to arrive and ask.

The two ifs are sequential, not elif. The first block can set half_open, and the second block then runs in the same call — so the request that discovers the cooldown has expired is itself the probe. Making the second an elif would cost one extra request-time before probing.

< self.cooldown, not <=. With cooldown=5 and opened_at=0, ticks 1 through 4 are rejected and tick 5 probes. I ran the one-character counterfactual rather than reasoning about it:

=== A. one character: `<` vs `<=` in the cooldown comparison === dead, as written (< cooldown) completed= 2350 backlog= 0 over at 21 dead, counterfactual (<= cooldown) completed= 2350 backlog= 0 over at 23 overloaded, as written (< cooldown) completed= 1415 backlog= 935 over at never overloaded, counterfactual (<= cooldown) completed= 1225 backlog= 1125 over at never

One character delays the dead dependency's recovery by two ticks (21 → 23) and costs the overloaded one 190 completed requests over 400 ticks. Note the direction: the character makes the cooldown one tick longer, and the overloaded case gets worse. That is the first hint of §6.4 — with this breaker, on this dependency, time spent open is not free, it is throughput you deleted.

test_open_rejects_until_exactly_cooldown (test_circuit_breaker.py lines 61–70) pins the boundary.

5.4 record — one probe decides everything

circuit_breaker.py · lines 65–80
    def record(self, ok, now):
        """Feed back the outcome of a call that allow() admitted."""
        if self.state == "half_open":
            # A probe is a verdict on the whole dependency, either way.
            if ok:
                self.state = "closed"
                self.outcomes.clear()
            else:
                self._trip(now)
            return

        self.outcomes.append(ok)
        if len(self.outcomes) < self.min_calls:
            return
        if self.outcomes.count(False) / len(self.outcomes) >= self.error_rate:
            self._trip(now)

This is the load-bearing method, and the load-bearing branch is the first one. Look at the asymmetry between the two halves:

The care on the way down and the credulity on the way up are hard to justify together. The usual defence is that a probe is expensive — you are deliberately sacrificing a real user's request to a possibly-dead service, so you take as few as you can. That defence is about the cost of the sample. It says nothing about whether the sample is representative, and §6 is entirely about the gap between those two things.

self.outcomes.clear() on close (and in _trip) means the breaker never carries evidence across a state change. It reads like a load-bearing line — surely the failures still sitting in the ring from before the trip would re-trip the breaker the moment it closed? — so I wrote both counterfactuals and ran them:

as written dead completed= 2350 backlog= 0 over at 21 as written overloaded completed= 1415 backlog= 935 over at None no clear on close dead completed= 2350 backlog= 0 over at 21 no clear on close overloaded completed= 1415 backlog= 935 over at None no clear on trip dead completed= 2350 backlog= 0 over at 21 no clear on trip overloaded completed= 1415 backlog= 935 over at None

Identical, all six. Neither clear() matters on this trace, and the reason is maxlen=20: the tick after a close delivers hundreds of outcomes, so the ring is completely overwritten before anything can be read from it. The line is correct defensive hygiene — it would matter for a dependency called a few times a tick, where 20 stale failures could survive several ticks — but on this workload it is not where the behaviour lives. Worth knowing before writing a paragraph claiming otherwise.

5.5 simulate — where the herd comes from

circuit_breaker.py · lines 147–153
        for attempts in queue:
            if breaker.allow(now):
                admitted.append(attempts)
            elif max_attempts is not None and attempts + 1 >= max_attempts:
                dropped += 1
            else:
                pending.append(attempts + 1)

A request the breaker refuses is not discarded — it goes on pending and is offered again on the next tick. That is the single most consequential line in the driver, and it is there because it is what real clients do. A user whose page failed reloads it; an SDK with a retry policy retries; a queue consumer that couldn't process a message leaves it on the queue.

The consequence An open breaker does not remove load, it stores it. While the breaker is open the requests do not vanish; they accumulate in the caller, and they are all delivered in a single tick the moment the breaker closes. §8 is honest about how much of the headline this line is responsible for.

max_attempts exists so you can turn that off and measure the difference (§8.1); the demo leaves it at None.


6. The demo, and what it proves

demo.py builds one trace — 5 requests a tick, surging to 40 for ticks 10 through 19 — and one breaker configuration, and runs both dependencies through them.

demo.py · lines 14–17
BASE = 5            # requests per tick in steady state
SPIKE = 40          # requests per tick during the surge
SURGE = (10, 20)    # ticks [10, 20) — also exactly when the dead one is down
CAPACITY = 20       # the overloaded dependency's ceiling, 4x the baseline

Note CAPACITY = 20 against BASE = 5. The overloaded dependency has four times the headroom it needs in steady state. It is not a marginal service; it is a comfortably provisioned one that gets briefly hit with 40.

demo.py · lines 26–28
def breaker():
    return CircuitBreaker(window=20, min_calls=10, error_rate=0.5,
                          cooldown=5, probes=1)

One factory, called for both columns. Same window, same threshold, same cooldown, same single probe.

6.1 The transcript

python3 demo.py
Same trace, same breaker. 5 req/tick, surging to 40 for ticks 10-19. DEAD (down 10-20) OVERLOADED (capacity 20/tick) t arr state call ok fail retry state call ok fail retry 0 5 closed 5 5 0 0 closed 5 5 0 0 1 5 closed 5 5 0 0 closed 5 5 0 0 2 5 closed 5 5 0 0 closed 5 5 0 0 3 5 closed 5 5 0 0 closed 5 5 0 0 4 5 closed 5 5 0 0 closed 5 5 0 0 5 5 closed 5 5 0 0 closed 5 5 0 0 6 5 closed 5 5 0 0 closed 5 5 0 0 7 5 closed 5 5 0 0 closed 5 5 0 0 8 5 closed 5 5 0 0 closed 5 5 0 0 9 5 closed 5 5 0 0 closed 5 5 0 0 10 40 closed 40 0 40 40 closed 40 20 20 20 11 40 open 0 0 0 80 open 0 0 0 60 12 40 open 0 0 0 120 open 0 0 0 100 13 40 open 0 0 0 160 open 0 0 0 140 14 40 open 0 0 0 200 open 0 0 0 180 15 40 half_open 1 0 1 240 half_open 1 1 0 219 <- probe 16 40 open 0 0 0 280 closed 259 20 239 239 17 40 open 0 0 0 320 open 0 0 0 279 18 40 open 0 0 0 360 open 0 0 0 319 19 40 open 0 0 0 400 open 0 0 0 359 20 5 half_open 1 1 0 404 open 0 0 0 364 <- probe 21 5 closed 409 409 0 0 half_open 1 1 0 368 <- probe 22 5 closed 5 5 0 0 closed 373 20 353 353 23 5 closed 5 5 0 0 open 0 0 0 358 24 5 closed 5 5 0 0 open 0 0 0 363 25 5 closed 5 5 0 0 open 0 0 0 368 26 5 closed 5 5 0 0 open 0 0 0 373 27 5 closed 5 5 0 0 half_open 1 1 0 377 <- probe 28 5 closed 5 5 0 0 closed 382 20 362 362 29 5 closed 5 5 0 0 open 0 0 0 367 30 5 closed 5 5 0 0 open 0 0 0 372 31 5 closed 5 5 0 0 open 0 0 0 377 32 5 closed 5 5 0 0 open 0 0 0 382 33 5 closed 5 5 0 0 half_open 1 1 0 386 <- probe 34 5 closed 5 5 0 0 closed 391 20 371 371 35 5 closed 5 5 0 0 open 0 0 0 376 36 5 closed 5 5 0 0 open 0 0 0 381 37 5 closed 5 5 0 0 open 0 0 0 386 38 5 closed 5 5 0 0 open 0 0 0 391 39 5 closed 5 5 0 0 half_open 1 1 0 395 <- probe 40 5 closed 5 5 0 0 closed 400 20 380 380 41 5 closed 5 5 0 0 open 0 0 0 385 42 5 closed 5 5 0 0 open 0 0 0 390 43 5 closed 5 5 0 0 open 0 0 0 395 44 5 closed 5 5 0 0 open 0 0 0 400 --- after 45 ticks ------------------------------------- dependency calls failed completed backlog over at dead no breaker 2775 2200 575 0 20 dead breaker 616 41 575 0 21 overloaded no breaker 2910 2335 575 0 33 overloaded breaker 1900 1725 175 400 never --- does it ever end? (400 ticks) -------------------- dead no breaker completed= 2350 trips= 0 backlog= 0 over at 20 dead breaker completed= 2350 trips= 2 backlog= 0 over at 21 overloaded no breaker completed= 2350 trips= 0 backlog= 0 over at 33 overloaded breaker completed= 1415 trips= 65 backlog= 935 over at never

The retry column is the number of requests sitting in the caller waiting for another go. On the left it returns to 0 at tick 21 and stays there. On the right it never returns to 0 again.

6.2 The dead dependency: the breaker is free money

Derive the two headline numbers.

Calls delivered to a dead service, without a breaker: 2775. The dependency is down for ticks 10–19. At tick 10, 40 requests arrive and all 40 fail; at tick 11 those 40 retry alongside 40 new ones, so 80 calls fail; and so on. That is 40 × (1 + 2 + … + 10) = 40 × 55 = 2200 — which is exactly the failed column. Add the calls that worked: 5 a tick for ticks 0–9, the 405 that drain at tick 20 (400 backlogged plus 5 new), and 5 a tick for ticks 21–44. 50 + 405 + 120 = 575, which is the completed column, and 2200 + 575 = 2775. Every one of those 2200 failures occupied a connection and waited out a timeout for nothing.

With a breaker: 616 calls, of which 41 failed. The 41 is the whole story: 40 calls at tick 10 (the breaker needs min_calls=10 outcomes before it can trip, and it gets them mid-tick, but the tick's calls were all admitted before any outcome came back), plus one failed probe at tick 15. That is a 98.1% reduction in doomed calls — 1 − 41/2200.

And it cost nothing. Look at completed in both rows: 575 and 575 over 45 ticks, 2350 and 2350 over 400. Not one request was lost by shedding, because a dead dependency was completing zero requests anyway. The breaker's only price is one tick of recovery lag — over at 20 becomes over at 21 — because the dependency comes back at tick 20 and the breaker's probe finds out at tick 20, closing in time for tick 21 to drain the entire 409-deep backlog in one go.

The two probes are the reason it works, and they are worth reading as measurements:

=== Claim 6: the probe verdict, tick by tick === dead: probe ticks (now, ok, failed) = [(15, 0, 1), (20, 1, 0)] the tick AFTER each probe (now, calls, ok, fail) = [(16, 0, 0, 0), (21, 409, 409, 0)]

Two probes, two correct verdicts. At tick 15 the service is still down and the probe fails — correctly. At tick 20 it is back and the probe succeeds — correctly. And the tick after that success, when 409 calls arrive instead of 1, all 409 succeed. The one-call sample generalised to a 409-call load, because for this dependency the number of calls was never part of the answer.

6.3 The overloaded dependency: every probe succeeds, every probe is wrong

The same measurement, same breaker, other column:

overloaded: probe ticks (now, ok, failed) = [(15, 1, 0), (21, 1, 0), (27, 1, 0), (33, 1, 0), (39, 1, 0)] the tick AFTER each probe (now, calls, ok, fail) = [(16, 259, 20, 239), (22, 373, 20, 353), (28, 382, 20, 362), (34, 391, 20, 371), (40, 400, 20, 380)]

Five probes. Five successes. Five refutations, each one tick later.

The probe at tick 15 sends 1 call. min(1, 20) is 1, so it succeeds — and it should, because the dependency genuinely is healthy at a load of 1. The breaker closes on that evidence and at tick 16 delivers the 259 requests it has been storing. min(259, 20) is 20. Two hundred and thirty-nine fail, the error rate is 92.3%, and the breaker trips again.

Nothing here is a bug in the breaker. It measured correctly, drew a valid conclusion about the load level it measured, and that load level ceased to exist the instant it acted on it. test_probe_verdict_generalizes_only_for_the_dead_dependency (test_circuit_breaker.py lines 104–128) asserts all ten of those tuples.

6.4 The arithmetic: why it never ends

The interesting claim is not that the breaker flaps. It is that the flapping is stable and lossy, and you can compute exactly how lossy.

From tick 21 the trace settles into a cycle. Here it is at tick 300, long after the surge is a memory:

now state calls ok fail retry 300 open 0 0 0 777 301 open 0 0 0 782 302 open 0 0 0 787 303 half_open 1 1 0 791 304 closed 796 20 776 776 305 open 0 0 0 781 306 open 0 0 0 786 completions per 6-tick period: 21 then 21 arrivals per 6-tick period: 30 backlog at ticks 300 / 306 / 312: 777 / 786 / 795

The period is 6 ticks: four open, one half-open, one closed. Count what the dependency completes in one period:

1 closed tick × 20 (its capacity) = 20 + 1 probe tick × 1 = 1 ─── completions per 6-tick period 21

So the breaker's effective throughput is 21/6 = 3.50 requests per tick. Arrivals are 5 per tick, or 30 per period. The deficit is 30 − 21 = 9 requests per cycle, and you can watch it in the retry column: 777 at tick 300, 786 at 306, 795 at 312. Exactly 9, forever. test_limit_cycle_arithmetic (test_circuit_breaker.py lines 151–162) asserts all of it.

Now put that number next to the dependency's actual capacity:

requests/tick
what the dependency can serve20.00
baseline arrival rate5.00
what the breaker lets it serve3.50

The dependency had 4× headroom. The breaker gave it a 30% deficit. It did this not by malfunctioning but by working exactly as designed — spending five ticks in six not using a service that was, throughout, ready to serve 20 a tick. The surge lasted 10 ticks. Without a breaker the backlog drains and the incident is over at 33. With one, at tick 400 the backlog is 935, and at tick 3000 it is 4822 — still rising, at the same 1.5 a tick.

This is congestion collapse, and the breaker produced it Both halves of the definition are here. Over 400 ticks the offered load rises from 4685 calls without a breaker to 41490 with one — nearly 9× the traffic, because every open tick stores requests that are re-offered on every subsequent tick. Over the same 400 ticks goodput falls from 2350 completions to 1415. More work offered, less work done. Congestion collapse is normally what a protection mechanism is supposed to prevent; here it is what the protection mechanism produced.

6.5 The boundary condition — where the effect vanishes

Two boundaries, both measured, and you need both to place your own system.

Boundary one: no overload, no divergence. If the surge never exceeds capacity, there are no failures, the breaker never trips, and the two arms are byte-identical:

=== C. the surge never exceeds capacity (spike=20 == capacity) === spike=20, no breaker completed= 2150 trips= 0 backlog= 0 over at 20 spike=20, breaker completed= 2150 trips= 0 backlog= 0 over at 20

test_boundary_no_overload_no_divergence (test_circuit_breaker.py lines 184–191) asserts the two totals() dicts are equal. A breaker in front of a dependency that never saturates is inert, and its configuration does not matter at all.

Boundary two — the sharp one: the baseline rate versus 3.50/tick. The limit cycle is only unbounded because arrivals (5/tick) exceed the breaker's effective throughput (3.50/tick). Lower the baseline and the same breaker, the same dependency and the same surge recover:

=== D. the boundary: baseline vs the duty-cycled throughput === cycle = 6 ticks, completions = 20 (closed) + 1 (probe) = 21 so the breaker's effective throughput is 21/6 = 3.50 requests/tick base=5/tick backlog slope +1.50/tick over at never base=4/tick backlog slope +0.50/tick over at never base=3/tick backlog slope +0.00/tick over at 701 base=2/tick backlog slope +0.00/tick over at 251

The measured backlog slope is the arithmetic and nothing else: at 5/tick the deficit is 5 − 3.50 = 1.50, and the measured slope is +1.50. At 4/tick it is +0.50. At 3/tick the baseline is below 3.50, the slope is flat, and the incident ends at tick 701.

Where the effect vanishes This bites you when your steady-state load is more than the fraction of capacity your breaker's duty cycle leaves you. Very lightly loaded systems will not see it at all — which is exactly why the failure mode is so easy to miss in testing, and shows up on the day you are busy.

6.6 The trace is pinned by tests

python3 test_circuit_breaker.py
PASS test_dead_dependency_ignores_load PASS test_overloaded_dependency_ignores_time PASS test_trips_on_error_rate_not_before_min_calls PASS test_open_rejects_until_exactly_cooldown PASS test_half_open_admits_exactly_probes_per_tick PASS test_one_probe_decides_everything PASS test_probe_verdict_generalizes_only_for_the_dead_dependency PASS test_headline_the_incident_never_ends PASS test_limit_cycle_arithmetic PASS test_cooldown_is_the_load_bearing_knob PASS test_boundary_no_overload_no_divergence PASS test_bounded_retries_are_a_co_author All 12 tests PASSED

The demo output is byte-identical across runs and across PYTHONHASHSEED values, because there is no clock and no RNG anywhere in the toy — see §7.4.


7. Design decisions and roads not taken

7.1 The knob everyone tunes does nothing

error_rate is the knob with "threshold" in its name in every real implementation, the one that shows up in every tuning guide, and the one an on-call engineer reaches for after an incident like this. Here is what it is worth on the overloaded dependency, over 400 ticks:

=== Claim 1: the error-rate threshold is inert (overloaded, 400 ticks) === error_rate=0.30 completed= 1415 trips= 65 backlog= 935 over at never error_rate=0.50 completed= 1415 trips= 65 backlog= 935 over at never error_rate=0.70 completed= 1415 trips= 65 backlog= 935 over at never error_rate=0.90 completed= 1415 trips= 65 backlog= 935 over at never error_rate=0.99 completed= 1415 trips= 65 backlog= 935 over at never

Not "barely moves" — identical, to the request, across a threshold range from 30% to 99%. test_cooldown_is_the_load_bearing_knob (test_circuit_breaker.py lines 165–181) asserts the totals() dicts are equal for all five.

The reason is worth having, because it generalises well beyond this toy. Once the herd exists, the tick after each close delivers 259, 373, 382, 391, 400 calls against a capacity of 20 — error rates of 92%, 95%, 95%, 95%, 95%. Any threshold below 92% trips on that. The threshold is a knob for distinguishing degrees of unhealthiness, and a saturated dependency does not offer degrees: it offers capacity successes and everything else. You cannot tune your way out of this with the trip threshold, because the trip is correct. It is the close that was wrong.

7.2 The knob that does everything is the cooldown, and it has a cliff

§6.4 gives the model: the breaker's effective throughput is one closed tick's worth of capacity plus one probe, spread over the whole cycle.

throughput = (capacity + probes) / (cooldown + 1)

and the system is stable exactly when that exceeds the baseline arrival rate. With capacity=20, probes=1 and a baseline of 5, that predicts a cliff between cooldown=3 (5.25/tick, stable) and cooldown=4 (4.20/tick, unstable). Measured against 3000 ticks:

cooldown predicted surplus measured slope over at 1 10.50 +5.50 -5.69 71 2 7.00 +2.00 -2.08 176 3 5.25 +0.25 -0.28 1315 4 4.20 -0.80 +0.88 never 5 3.50 -1.50 +1.52 never 10 1.91 -3.09 +3.10 never

The predicted surplus and the measured backlog slope are the same number with the sign flipped, at every row. The cliff is exactly where the formula puts it: cooldown=3 drains, slowly, finishing at tick 1315; cooldown=4 never does. test_cooldown_is_the_load_bearing_knob pins both.

This inverts the usual advice The intuition behind a long cooldown is "give it time to recover" — and for a dead dependency that is fine, since the extra open ticks cost nothing (§6.2). For an overloaded dependency it is exactly backwards: the dependency recovers the instant load drops, and every extra open tick is capacity you declined to use. The cooldown is not a recovery budget, it is a throughput divisor.

7.3 The probe budget: tuning against a number you cannot see

probes is resilience4j's permittedNumberOfCallsInHalfOpenState, and it is the other half of the same fraction. Raising it raises the numerator. Over 400 ticks on the overloaded dependency:

probes= 1 throughput=(20+1)/6= 3.50 completed= 1415 backlog= 935 over at never probes= 3 throughput=(20+3)/6= 3.83 completed= 1545 backlog= 805 over at never probes= 5 throughput=(20+5)/6= 4.17 completed= 1675 backlog= 675 over at never probes= 10 throughput=(20+10)/6= 5.00 completed= 2000 backlog= 350 over at never probes= 15 throughput=(20+15)/6= 5.83 completed= 2325 backlog= 25 over at never probes= 20 throughput=(20+20)/6= 6.67 completed= 2350 backlog= 0 over at 209 probes= 40 throughput=(20+40)/6=10.00 completed= 1610 backlog= 740 over at never

Note probes=3 — resilience4j's default — is still catastrophic, and probes=40 is catastrophic again after probes=20 worked. Sweeping every value from 1 to 59:

probes values that recover: [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29]

There is a correct setting, and it is a window bracketing the dependency's capacity of 20. Below 16 the duty cycle is too thin to keep up with the baseline. Above 29 the probe batch itself exceeds what the dependency can serve, so the probe fails and the breaker re-opens without ever closing — here is probes=40 failing inside its own probe tick:

probes=40, ticks 14-18: 14 open calls 0 ok 0 fail 0 15 half_open calls 40 ok 20 fail 20 16 open calls 0 ok 0 fail 0 17 open calls 0 ok 0 fail 0 18 open calls 0 ok 0 fail 0

So the knob is tunable in principle and untunable in practice: the target is a number on the server side, that the client has no way to observe, that changes with every deploy and every autoscaling event. This is the deep problem with the whole approach, and §7.5 is about what people do instead.

7.4 Roads not taken inside the breaker

Requiring several consecutive probe successes before closing. The obvious fix for a bad sample is more samples, so I built it and ran it:

=== B. demand 3 consecutive probe successes instead of 1 === dead, 1 success (as written) completed= 2350 backlog= 0 over at 21 dead, 3 successes completed= 2350 backlog= 0 over at 23 overloaded, 1 success (as written) completed= 1415 backlog= 935 over at never overloaded, 3 successes completed= 1175 backlog= 1175 over at never

It makes the overloaded case worse (1415 → 1175 completed), and the dead case worse too (over at 21 → 23). Both for the same reason: the extra probes are admitted one per tick, so they lengthen the cycle without measuring anything new. Three samples at a load of 1 tell you precisely what one sample at a load of 1 told you, and you paid two more open ticks for them. This is the clearest single result in the toy: the problem is not the number of observations, it is the load they were taken at.

Not refreshing the probe budget each tick (making probes a budget for the whole half-open period rather than per tick). At probes=1 this is measurably identical (1415 completed, backlog 935, both dependencies unchanged), so the if now != self.probe_tick reset in allow is not a load-bearing line at the demo's settings. Worth knowing before writing a paragraph claiming it is.

A Half-open class, or a state pattern. Three states, three string literals, and transitions in two methods. An enum would be tidier and a state class per state would be a file's worth of ceremony hiding the thing worth seeing, which is that of the four transitions allow owns exactly one (open → half_open) and record owns the other three.

No clock, no RNG. allow(now) takes integer ticks and the dependencies are pure functions of (now, k). The usual reason is testability, but here there is a stronger one: the aha is a limit cycle, and a limit cycle observed under jitter is indistinguishable from noise. Making the toy fully deterministic is what lets §6.4 say "exactly 9 per cycle, forever" and have a test assert it. The output is byte-identical across PYTHONHASHSEED=0, 1, 42 and 12345.

7.5 The famous alternatives, and why they are not here

A rate limiter is the closest neighbour, and this repo has one: see rate-limiter. A token bucket in place of this breaker would have throttled the surge to a steady 20/tick instead of alternating between 0 and 400 — the same "reduce load" goal with a continuous knob rather than a switch.


8. What's simplified vs. the real thing

8.1 The retry policy is a co-author of the headline — measured

This is the objection to make first, so §6.4's headline gets stated honestly: isn't this just congestion collapse wearing a breaker costume? Partly yes. The simulate loop retries forever (§5.5), and an unbounded retry policy is independently capable of destroying a system with no breaker in sight.

max_attempts exists to measure the split. Capping every request at 3 attempts, over 400 ticks:

=== Claim 4: bounded retries (max_attempts=3), 400 ticks === dead no breaker completed= 2030 dropped= 320 backlog= 0 over at 20 dead breaker completed= 1991 dropped= 359 backlog= 0 over at 21 overloaded no breaker completed= 2190 dropped= 160 backlog= 0 over at 22 overloaded breaker completed= 1992 dropped= 358 backlog= 0 over at 22

Two things are true at once, and the page needs both:

  1. Bounding retries ends the catastrophe. "Backlog 935 and climbing at tick 400" becomes "drained at tick 22, 358 dropped". The unbounded limit cycle of §6.4 requires the retry policy as much as it requires the breaker. If you take one operational lesson from this page, take that one: an unbounded retry loop in front of a breaker is the actual hazard.
  2. The breaker still owns its share. With retries bounded identically in both arms, the overloaded dependency drops 358 requests with a breaker against 160 without — 2.2× worse — while the dead dependency drops 359 against 320, a difference of 39 that is essentially the one failed probe and its retries. So the breaker's own contribution is real, it is specific to the load-induced case, and it survives the fix.

The mechanism behind that residual 2.2× is not the retries. It is that a breaker's only load-management move is all-or-nothing, and it spends most of an incident in "nothing" in front of a dependency that was serving its full 20 a tick throughout. test_bounded_retries_are_a_co_author (test_circuit_breaker.py lines 194–214) pins all four numbers.

8.2 The other simplifications


9. Check yourself

Answer before expanding. Each answer is derivable from the source.

Question 1

At tick 15 the breaker probes both dependencies and gets ok from the overloaded one, fail from the dead one. Which of those two breakers made the correct decision?

Answer

Both did, and that is the point. Each probe accurately reported the outcome of the call it made. The dead dependency really was still down at tick 15; the overloaded dependency really was healthy for a single call, since min(1, 20) == 1.

The difference is not in the measurement, it is in what the measurement licenses. DeadDependency.call ignores k (circuit_breaker.py lines 108–111), so a result obtained at k=1 holds at k=409 — and the transcript confirms it: tick 21 admits 409 calls and completes all 409. OverloadedDependency.call ignores now and branches on k (circuit_breaker.py lines 123–125), so a result obtained at k=1 says nothing about k=259. Tick 16 admits 259 and fails 239 of them.

A breaker is a machine for generalising from a sample. Nothing in the state machine records the load the sample was taken at.

Question 2

Without doing arithmetic on the transcript: the breaker is open 4 ticks in 6 and closed 1 in 6 in front of a dependency that can serve 20 a tick. Roughly what throughput does the caller get, and what does that make the maximum baseline load this system can sustain?

Answer

(capacity + probes) / (cooldown + 1) = (20 + 1) / 6 = 3.50 requests per tick — the closed tick contributes 20, the half-open tick contributes 1, and the four open ticks contribute nothing.

That is also the maximum sustainable baseline, and §6.5 measures it directly: at a baseline of 5/tick the backlog grows at 5 − 3.50 = +1.50/tick, at 4/tick it grows at +0.50, and at 3/tick it drains and the incident ends at tick 701.

The uncomfortable comparison is with the dependency's real capacity, 20. The breaker cut the usable throughput of a healthy service by 82.5%.

Question 3

Your incident review concludes the breaker was too twitchy and recommends raising error_rate from 0.5 to 0.9 so it only trips on severe failures. What happens?

Answer

Nothing at all. §7.1 measures 0.30, 0.50, 0.70, 0.90 and 0.99 and all five produce byte-identical results: 1415 completed, 65 trips, backlog 935, never recovers. test_cooldown_is_the_load_bearing_knob asserts the totals() dicts are equal.

The tick after each close delivers 259–400 calls against a capacity of 20, so the observed error rate is 92–95%. Every threshold at or below 0.9 fires on that, and 0.99 fails to prevent the next one. The trip was never the mistake; the close was. Tuning the trip threshold is tuning the wrong end of the cycle.

Question 4

Would raising probes from 1 to 3 (resilience4j's default) have fixed the overloaded case? What about requiring 3 consecutive probe successes before closing?

Answer

Neither, and the second actively hurts.

probes=3 moves completions from 1415 to 1545 over 400 ticks and still never recovers (§7.3) — it raises the duty-cycled throughput from (20+1)/6 = 3.50 to (20+3)/6 = 3.83, still below the 5/tick baseline. The sweep in §7.3 shows recovery needs probes in [16, 29], a window around the dependency's capacity of 20.

Requiring 3 consecutive successes is worse than the shipped single probe: 1175 completed against 1415, and the dead dependency's recovery slips from tick 21 to 23 (§7.4). The extra probes are admitted one per tick, so they lengthen the open period while measuring the dependency at a load of 1 three times instead of once. More samples do not fix a sample taken at the wrong load.

Question 5

You run this service on 12 hosts, each with its own in-process breaker, in front of a dependency that saturates at 240 requests/tick total. The dependency gets overloaded. What does the recovery look like, and what would you add?

Answer

Each host independently trips, waits its own cooldown, and probes. Two effects, pulling opposite ways:

Better than the toy: 12 hosts each admitting 1 probe deliver 12 probes per probe-tick, so the aggregate probe budget scales with the fleet — a partial, accidental version of the fix §7.3 could not tune by hand.

Worse than the toy: the hosts all tripped on the same event, so their cooldowns expire together. All 12 close in the same tick and deliver their stored backlogs simultaneously — §6.3's herd, times twelve. Without jitter the fleet oscillates in lockstep, and the aggregate looks exactly like the single-breaker limit cycle with bigger numbers.

What to add, in order of how much it buys: bound the retries (§8.1 — turns "never recovers" into "recovers at tick 22"); jitter the cooldown per host so the closes decorrelate; and then stop guessing — either shed load at the server, or replace the breaker with an adaptive concurrency limit (§7.5) that converges on 240 instead of bracketing it.

Question 6

For a dependency you actually operate, what single question tells you whether a breaker in front of it will help or hurt?

Answer

"When this fails, does it fail because of the traffic it is receiving?"

If no — a bad deploy, a dead node, an expired certificate, a downstream outage — then a breaker is close to free: §6.2 measures a 98.1% reduction in doomed calls (2200 → 41) at a cost of zero completed requests and one tick of lag.

If yes, a breaker is a coarse load-shedder that spends most of an incident refusing to use capacity that exists, and §6.4 measures the price: effective throughput of 3.50/tick against a real capacity of 20.

The trap is that most real dependencies are both, on different days, and the breaker's configuration cannot be conditioned on which day it is — because the only instrument it has is a single call, and §5.1 is the proof that a single call cannot tell the two apart.


10. Further reading

Every link below was fetched and confirmed live when this was written.