Skip to content
About
Main content

A circuit breaker that protects the caller

Simulate a modelNeeds Python

Problem

When a dependency hangs instead of failing, each call holds a thread until its . With 50 calls a second and a 3-second timeout, the caller needs 150 threads just to wait; a pool of 20 fills in under half a second, and from then on every request, even ones that do not need the dependency, behind the hung calls. A watches the error rate and, past a threshold, stops calling the dependency for a cooldown, answering at once with a fallback. It isolates a failure; it does not modulate a rate.

Create a dependency that sleeps 3 seconds on every call between seconds 10 and 40 of the run, and works normally otherwise. Call it from a pool of 20 threads at 50 calls/s with a 3-second timeout and no breaker: record how many callers are blocked and the p99 of the whole service. Repeat with a breaker (error rate over a window, 1-second timeout, 10-second cooldown, fallback returning a default). Finally, give the breaker a count threshold instead of a rate and observe that it trips wrongly at low traffic.

The script is a discrete-event simulation. For the last part it runs ten minutes with no outage and 5% ordinary errors, at 1 and at 50 calls per second.

The question. During the outage, how many callers queue and how slow does the service get with and without a breaker, and which breaker trips when nothing is wrong?


Predict

With nothing wrong, how often does a count breaker (trip on N failures) trip at 50 calls a second?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    a-circuit-breaker-that-protects-the.py99 linesDownload
    View the code
    a-circuit-breaker-that-protects-the.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """A circuit breaker that protects the caller.
    
    A service with a pool of 20 threads calls a dependency at 50 calls/s; the
    dependency hangs for 3 s on every call between seconds 10 and 40. Without a
    breaker (3 s timeout), and with a rate breaker (1 s timeout, opens at 50%
    errors over the last 20 calls, 10 s cooldown, fallback answer while open).
    Then, with no outage and 5% ordinary errors, how often a rate breaker and a
    count breaker trip wrongly at low and at high traffic. A discrete-event simulation.
    """
    import argparse, heapq, json, random
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--threads", type=int, default=20, help="Caller thread pool.")
    p.add_argument("--rate", type=float, default=50, help="Incoming calls per second.")
    p.add_argument("--seconds", type=float, default=60, help="Length of the run.")
    p.add_argument("--outage", type=lambda s: [float(v) for v in s.split(",")], default=[10, 40], help="Outage start,end in seconds.")
    p.add_argument("--cooldown", type=float, default=10, help="Seconds the breaker stays open.")
    p.add_argument("--seed", type=int, default=0, help="Random seed.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    random.seed(args.seed)
    
    
    def run(breaker, rate, timeout, seconds, outage, error_rate=0.0):
        """breaker: none | rate (>= 50% of the last 20 calls failed) | count (>= 5 failures in the last 10 s)."""
        free, waiting, events, per_sec = args.threads, [], [], {}
        open_until, recent, fail_times, trips = -1.0, [], [], 0
        t = 0.0
        while t < seconds:
            t += random.expovariate(rate); heapq.heappush(events, (t, 0, "arrive", t))
        def sec(x): return per_sec.setdefault(int(x), {"second": int(x), "lat": [], "queue": 0, "fallback": 0})
        def finish(now, born, fallback=False):
            s = sec(now); s["lat"].append(now - born); s["fallback"] += fallback
        def start(now, born):
            nonlocal free
            free -= 1
            hang = outage[0] <= now < outage[1]
            fail = hang or random.random() < error_rate
            dur = timeout if hang else (timeout if fail else random.uniform(0.005, 0.02))
            heapq.heappush(events, (now + dur, 1, "fail" if fail else "ok", born))
        def trip(now):
            nonlocal open_until, trips, recent, fail_times
            open_until, trips, recent, fail_times = now + args.cooldown, trips + 1, [], []
            while waiting: finish(now, waiting.pop(0), True)              # queued calls get the fallback at once
        while events:
            t, _, kind, born = heapq.heappop(events)
            if t > seconds + 30: break
            if kind == "arrive":
                if breaker != "none" and t < open_until: finish(t, born, True); continue
                if free > 0: start(t, born)
                else: waiting.append(born)
            else:
                free += 1
                finish(t, born)
                ok = kind == "ok"
                if breaker == "rate":
                    recent = (recent + [ok])[-20:]
                    if len(recent) == 20 and recent.count(False) >= 10 and t >= open_until: trip(t)
                elif breaker == "count":
                    if not ok: fail_times = [x for x in fail_times + [t] if t - x < 10]
                    if len(fail_times) >= 5 and t >= open_until: trip(t)
                if waiting and free > 0: start(t, waiting.pop(0))
            sec(t)["queue"] = max(sec(t)["queue"], len(waiting))
        rows = []
        for s in sorted(per_sec.values(), key=lambda x: x["second"]):
            lat = sorted(s.pop("lat")); s["p99_s"] = lat[max(0, int(len(lat) * 0.99) - 1)] if lat else 0.0; rows.append(s)
        return rows, trips
    
    
    rows = []
    summary = []
    for breaker, label, timeout in (("none", "no breaker, 3 s timeout", 3.0), ("rate", "rate breaker, 1 s timeout", 1.0)):
        secs, trips = run(breaker, args.rate, timeout, args.seconds, args.outage)
        for s in secs: s["setup"] = label
        rows += secs
        during = [s for s in secs if args.outage[0] + 2 <= s["second"] < args.outage[1]]
        recovered = next((s["second"] for s in secs if s["second"] >= args.outage[1] and s["p99_s"] < 0.1 and s["queue"] == 0), None)
        summary.append({"setup": label, "max_queue": max(s["queue"] for s in secs), "worst_p99_s": max(s["p99_s"] for s in during),
                        "fallbacks": sum(s["fallback"] for s in during), "recovered_at_s": recovered if recovered is not None else -1})
        x = summary[-1]
        print(f"{label:<26} callers waiting at most {x['max_queue']:>5}   worst p99 during the outage {x['worst_p99_s']:6.2f} s   "
              f"fallbacks {x['fallbacks']:>5}   back to p99 < 0.1 s at second {x['recovered_at_s']}")
    
    wrong = []
    for traffic in (1.0, args.rate):
        for breaker in ("rate", "count"):
            _, trips = run(breaker, traffic, 1.0, 600, [1e9, 1e9], error_rate=0.05)
            wrong.append({"breaker": f"{breaker} breaker", "calls_per_s": traffic, "wrong_trips_per_10_min": trips})
            print(f"no outage, 5% ordinary errors, {traffic:>4g} calls/s: {breaker} breaker tripped {trips} times in 10 minutes")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"seconds": rows, "summary": summary, "wrong_trips": wrong}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run a-circuit-breaker-that-protects-the.py
    uv run a-circuit-breaker-that-protects-the.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--threads20 Caller thread pool.
--rate50 Incoming calls per second.
--seconds60 Length of the run.
--outage10, 40 Outage start,end in seconds.
--cooldown10 Seconds the breaker stays open.
--seed0 Random seed.

Our run

Service p99 latency per second (outage from 10 s to 40 s)Apple M2 · 8 cores · 8 GB
no breaker, 3 s timeoutrate breaker, 1 s timeout
Data table
Service p99 latency per second (outage from 10 s to 40 s)
Secondseriesp99 latency (s)
0no breaker, 3 s timeout19.1 ms
1no breaker, 3 s timeout19.6 ms
2no breaker, 3 s timeout18.5 ms
3no breaker, 3 s timeout19.7 ms
4no breaker, 3 s timeout19.8 ms
5no breaker, 3 s timeout19.4 ms
6no breaker, 3 s timeout19.5 ms
7no breaker, 3 s timeout19.5 ms
8no breaker, 3 s timeout19.6 ms
9no breaker, 3 s timeout18.1 ms
10no breaker, 3 s timeout19.2 ms
11no breaker, 3 s timeout0 µs
12no breaker, 3 s timeout0 µs
13no breaker, 3 s timeout3 s
14no breaker, 3 s timeout0 µs
15no breaker, 3 s timeout0 µs
16no breaker, 3 s timeout5.67 s
17no breaker, 3 s timeout0 µs
18no breaker, 3 s timeout0 µs
19no breaker, 3 s timeout8.21 s
20no breaker, 3 s timeout0 µs
21no breaker, 3 s timeout0 µs
22no breaker, 3 s timeout10.9 s
23no breaker, 3 s timeout0 µs
24no breaker, 3 s timeout0 µs
25no breaker, 3 s timeout13.5 s
26no breaker, 3 s timeout0 µs
27no breaker, 3 s timeout0 µs
28no breaker, 3 s timeout16.2 s
29no breaker, 3 s timeout0 µs
30no breaker, 3 s timeout0 µs
31no breaker, 3 s timeout18.8 s
32no breaker, 3 s timeout0 µs
33no breaker, 3 s timeout0 µs
34no breaker, 3 s timeout21.5 s
35no breaker, 3 s timeout0 µs
36no breaker, 3 s timeout0 µs
37no breaker, 3 s timeout24.2 s
38no breaker, 3 s timeout0 µs
39no breaker, 3 s timeout0 µs
40no breaker, 3 s timeout26.7 s
41no breaker, 3 s timeout1.58 s
42no breaker, 3 s timeout19.4 ms
43no breaker, 3 s timeout19.9 ms
44no breaker, 3 s timeout18.2 ms
45no breaker, 3 s timeout19.3 ms
46no breaker, 3 s timeout19.6 ms
47no breaker, 3 s timeout19.5 ms
48no breaker, 3 s timeout19.2 ms
49no breaker, 3 s timeout18.6 ms
50no breaker, 3 s timeout19.7 ms
51no breaker, 3 s timeout19.3 ms
52no breaker, 3 s timeout18.4 ms
53no breaker, 3 s timeout20 ms
54no breaker, 3 s timeout19.4 ms
55no breaker, 3 s timeout19.4 ms
56no breaker, 3 s timeout19.6 ms
57no breaker, 3 s timeout19 ms
58no breaker, 3 s timeout19.4 ms
59no breaker, 3 s timeout19.3 ms
60no breaker, 3 s timeout9.97 ms
0rate breaker, 1 s timeout19.6 ms
1rate breaker, 1 s timeout19.6 ms
2rate breaker, 1 s timeout19.9 ms
3rate breaker, 1 s timeout19.2 ms
4rate breaker, 1 s timeout19.5 ms
5rate breaker, 1 s timeout19.2 ms
6rate breaker, 1 s timeout19.8 ms
7rate breaker, 1 s timeout19.7 ms
8rate breaker, 1 s timeout19.4 ms
9rate breaker, 1 s timeout20 ms
10rate breaker, 1 s timeout16.9 ms
11rate breaker, 1 s timeout1 s
12rate breaker, 1 s timeout1.61 s
13rate breaker, 1 s timeout0 µs
14rate breaker, 1 s timeout0 µs
15rate breaker, 1 s timeout0 µs
16rate breaker, 1 s timeout0 µs
17rate breaker, 1 s timeout0 µs
18rate breaker, 1 s timeout0 µs
19rate breaker, 1 s timeout0 µs
20rate breaker, 1 s timeout0 µs
21rate breaker, 1 s timeout0 µs
22rate breaker, 1 s timeout1 s
23rate breaker, 1 s timeout0 µs
24rate breaker, 1 s timeout0 µs
25rate breaker, 1 s timeout0 µs
26rate breaker, 1 s timeout0 µs
27rate breaker, 1 s timeout0 µs
28rate breaker, 1 s timeout0 µs
29rate breaker, 1 s timeout0 µs
30rate breaker, 1 s timeout0 µs
31rate breaker, 1 s timeout0 µs
32rate breaker, 1 s timeout0 µs
33rate breaker, 1 s timeout1 s
34rate breaker, 1 s timeout0 µs
35rate breaker, 1 s timeout0 µs
36rate breaker, 1 s timeout0 µs
37rate breaker, 1 s timeout0 µs
38rate breaker, 1 s timeout0 µs
39rate breaker, 1 s timeout0 µs
40rate breaker, 1 s timeout0 µs
41rate breaker, 1 s timeout0 µs
42rate breaker, 1 s timeout0 µs
43rate breaker, 1 s timeout0 µs
44rate breaker, 1 s timeout0 µs
45rate breaker, 1 s timeout0 µs
46rate breaker, 1 s timeout0 µs
47rate breaker, 1 s timeout0 µs
48rate breaker, 1 s timeout0 µs
49rate breaker, 1 s timeout0 µs
50rate breaker, 1 s timeout0 µs
51rate breaker, 1 s timeout0 µs
52rate breaker, 1 s timeout0 µs
53rate breaker, 1 s timeout19.5 ms
54rate breaker, 1 s timeout19.6 ms
55rate breaker, 1 s timeout18.8 ms
56rate breaker, 1 s timeout19.2 ms
57rate breaker, 1 s timeout19.6 ms
58rate breaker, 1 s timeout19.6 ms
59rate breaker, 1 s timeout19.5 ms
60rate breaker, 1 s timeout8.45 ms
macOS 26.6.2 · Python 3.13.3 · run 2026-10-25

During the outage

During the outage
SetupCallers waiting (max)Worst p99Fallback answersHealthy again at (s)
no breaker, 3 s timeout1,29024.2 s042
rate breaker, 1 s timeout451.61 s1,44040

No outage, 5% ordinary errors: trips in 10 minutes

No outage, 5% ordinary errors: trips in 10 minutes
BreakerCalls / sWrong trips
rate breaker10
count breaker11
rate breaker500
count breaker5048
Output · uv run a-circuit-breaker-that-protects-the.py
no breaker, 3 s timeout    callers waiting at most  1292   worst p99 during the outage  24.23 s   fallbacks     0   back to p99 < 0.1 s at second 42
rate breaker, 1 s timeout  callers waiting at most    45   worst p99 during the outage   1.61 s   fallbacks  1440   back to p99 < 0.1 s at second 40
no outage, 5% ordinary errors,    1 calls/s: rate breaker tripped 0 times in 10 minutes
no outage, 5% ordinary errors,    1 calls/s: count breaker tripped 1 times in 10 minutes
no outage, 5% ordinary errors,   50 calls/s: rate breaker tripped 0 times in 10 minutes
no outage, 5% ordinary errors,   50 calls/s: count breaker tripped 48 times in 10 minutes

What we found

Without a breaker, up to 1,290 callers were waiting for a thread and the worst p99 reached 24.2 s: the service was unusable for the whole outage, as expected. With the rate breaker, at most 45 waited, 1,440 requests got an immediate fallback, and the worst p99 was 1.61 s; latency was back under 100 ms at second 40.

The count breaker did not behave as the chapter says. With no outage at all, it tripped 48 times in ten minutes at 50 calls per second and 1 at 1 call per second: a fixed count of failures is reached by ordinary errors at high traffic, not at low. The breaker that misbehaves at low traffic is a rate breaker with no minimum number of calls, where one error in two calls is already 50%; this one requires 20 calls, so it did not trip at either rate.


Read it

Circuit breakers and are failure isolation, not flow control. Flow control modulates a rate; these sever paths. A breaker turns a slow failure into a fast one, which frees the threads that would have waited, and its fallback decides what users see in the meantime.

Base the trip on an error rate over enough calls, with a minimum volume, so it neither trips on noise at low traffic nor on the normal error count at high traffic.