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 timeout. 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, queues behind the hung calls. A circuit breaker 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 latency 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
Expect the first run to show all 20 threads blocked and the service unusable for the entire outage, and the second run to show fast fallback responses and recovery within about one cooldown after second 40.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide 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)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
| Knob · default | What 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
Data table
| Second | series | p99 latency (s) |
|---|---|---|
| 0 | no breaker, 3 s timeout | 19.1 ms |
| 1 | no breaker, 3 s timeout | 19.6 ms |
| 2 | no breaker, 3 s timeout | 18.5 ms |
| 3 | no breaker, 3 s timeout | 19.7 ms |
| 4 | no breaker, 3 s timeout | 19.8 ms |
| 5 | no breaker, 3 s timeout | 19.4 ms |
| 6 | no breaker, 3 s timeout | 19.5 ms |
| 7 | no breaker, 3 s timeout | 19.5 ms |
| 8 | no breaker, 3 s timeout | 19.6 ms |
| 9 | no breaker, 3 s timeout | 18.1 ms |
| 10 | no breaker, 3 s timeout | 19.2 ms |
| 11 | no breaker, 3 s timeout | 0 µs |
| 12 | no breaker, 3 s timeout | 0 µs |
| 13 | no breaker, 3 s timeout | 3 s |
| 14 | no breaker, 3 s timeout | 0 µs |
| 15 | no breaker, 3 s timeout | 0 µs |
| 16 | no breaker, 3 s timeout | 5.67 s |
| 17 | no breaker, 3 s timeout | 0 µs |
| 18 | no breaker, 3 s timeout | 0 µs |
| 19 | no breaker, 3 s timeout | 8.21 s |
| 20 | no breaker, 3 s timeout | 0 µs |
| 21 | no breaker, 3 s timeout | 0 µs |
| 22 | no breaker, 3 s timeout | 10.9 s |
| 23 | no breaker, 3 s timeout | 0 µs |
| 24 | no breaker, 3 s timeout | 0 µs |
| 25 | no breaker, 3 s timeout | 13.5 s |
| 26 | no breaker, 3 s timeout | 0 µs |
| 27 | no breaker, 3 s timeout | 0 µs |
| 28 | no breaker, 3 s timeout | 16.2 s |
| 29 | no breaker, 3 s timeout | 0 µs |
| 30 | no breaker, 3 s timeout | 0 µs |
| 31 | no breaker, 3 s timeout | 18.8 s |
| 32 | no breaker, 3 s timeout | 0 µs |
| 33 | no breaker, 3 s timeout | 0 µs |
| 34 | no breaker, 3 s timeout | 21.5 s |
| 35 | no breaker, 3 s timeout | 0 µs |
| 36 | no breaker, 3 s timeout | 0 µs |
| 37 | no breaker, 3 s timeout | 24.2 s |
| 38 | no breaker, 3 s timeout | 0 µs |
| 39 | no breaker, 3 s timeout | 0 µs |
| 40 | no breaker, 3 s timeout | 26.7 s |
| 41 | no breaker, 3 s timeout | 1.58 s |
| 42 | no breaker, 3 s timeout | 19.4 ms |
| 43 | no breaker, 3 s timeout | 19.9 ms |
| 44 | no breaker, 3 s timeout | 18.2 ms |
| 45 | no breaker, 3 s timeout | 19.3 ms |
| 46 | no breaker, 3 s timeout | 19.6 ms |
| 47 | no breaker, 3 s timeout | 19.5 ms |
| 48 | no breaker, 3 s timeout | 19.2 ms |
| 49 | no breaker, 3 s timeout | 18.6 ms |
| 50 | no breaker, 3 s timeout | 19.7 ms |
| 51 | no breaker, 3 s timeout | 19.3 ms |
| 52 | no breaker, 3 s timeout | 18.4 ms |
| 53 | no breaker, 3 s timeout | 20 ms |
| 54 | no breaker, 3 s timeout | 19.4 ms |
| 55 | no breaker, 3 s timeout | 19.4 ms |
| 56 | no breaker, 3 s timeout | 19.6 ms |
| 57 | no breaker, 3 s timeout | 19 ms |
| 58 | no breaker, 3 s timeout | 19.4 ms |
| 59 | no breaker, 3 s timeout | 19.3 ms |
| 60 | no breaker, 3 s timeout | 9.97 ms |
| 0 | rate breaker, 1 s timeout | 19.6 ms |
| 1 | rate breaker, 1 s timeout | 19.6 ms |
| 2 | rate breaker, 1 s timeout | 19.9 ms |
| 3 | rate breaker, 1 s timeout | 19.2 ms |
| 4 | rate breaker, 1 s timeout | 19.5 ms |
| 5 | rate breaker, 1 s timeout | 19.2 ms |
| 6 | rate breaker, 1 s timeout | 19.8 ms |
| 7 | rate breaker, 1 s timeout | 19.7 ms |
| 8 | rate breaker, 1 s timeout | 19.4 ms |
| 9 | rate breaker, 1 s timeout | 20 ms |
| 10 | rate breaker, 1 s timeout | 16.9 ms |
| 11 | rate breaker, 1 s timeout | 1 s |
| 12 | rate breaker, 1 s timeout | 1.61 s |
| 13 | rate breaker, 1 s timeout | 0 µs |
| 14 | rate breaker, 1 s timeout | 0 µs |
| 15 | rate breaker, 1 s timeout | 0 µs |
| 16 | rate breaker, 1 s timeout | 0 µs |
| 17 | rate breaker, 1 s timeout | 0 µs |
| 18 | rate breaker, 1 s timeout | 0 µs |
| 19 | rate breaker, 1 s timeout | 0 µs |
| 20 | rate breaker, 1 s timeout | 0 µs |
| 21 | rate breaker, 1 s timeout | 0 µs |
| 22 | rate breaker, 1 s timeout | 1 s |
| 23 | rate breaker, 1 s timeout | 0 µs |
| 24 | rate breaker, 1 s timeout | 0 µs |
| 25 | rate breaker, 1 s timeout | 0 µs |
| 26 | rate breaker, 1 s timeout | 0 µs |
| 27 | rate breaker, 1 s timeout | 0 µs |
| 28 | rate breaker, 1 s timeout | 0 µs |
| 29 | rate breaker, 1 s timeout | 0 µs |
| 30 | rate breaker, 1 s timeout | 0 µs |
| 31 | rate breaker, 1 s timeout | 0 µs |
| 32 | rate breaker, 1 s timeout | 0 µs |
| 33 | rate breaker, 1 s timeout | 1 s |
| 34 | rate breaker, 1 s timeout | 0 µs |
| 35 | rate breaker, 1 s timeout | 0 µs |
| 36 | rate breaker, 1 s timeout | 0 µs |
| 37 | rate breaker, 1 s timeout | 0 µs |
| 38 | rate breaker, 1 s timeout | 0 µs |
| 39 | rate breaker, 1 s timeout | 0 µs |
| 40 | rate breaker, 1 s timeout | 0 µs |
| 41 | rate breaker, 1 s timeout | 0 µs |
| 42 | rate breaker, 1 s timeout | 0 µs |
| 43 | rate breaker, 1 s timeout | 0 µs |
| 44 | rate breaker, 1 s timeout | 0 µs |
| 45 | rate breaker, 1 s timeout | 0 µs |
| 46 | rate breaker, 1 s timeout | 0 µs |
| 47 | rate breaker, 1 s timeout | 0 µs |
| 48 | rate breaker, 1 s timeout | 0 µs |
| 49 | rate breaker, 1 s timeout | 0 µs |
| 50 | rate breaker, 1 s timeout | 0 µs |
| 51 | rate breaker, 1 s timeout | 0 µs |
| 52 | rate breaker, 1 s timeout | 0 µs |
| 53 | rate breaker, 1 s timeout | 19.5 ms |
| 54 | rate breaker, 1 s timeout | 19.6 ms |
| 55 | rate breaker, 1 s timeout | 18.8 ms |
| 56 | rate breaker, 1 s timeout | 19.2 ms |
| 57 | rate breaker, 1 s timeout | 19.6 ms |
| 58 | rate breaker, 1 s timeout | 19.6 ms |
| 59 | rate breaker, 1 s timeout | 19.5 ms |
| 60 | rate breaker, 1 s timeout | 8.45 ms |
During the outage
| Setup | Callers waiting (max) | Worst p99 | Fallback answers | Healthy again at (s) |
|---|---|---|---|---|
| no breaker, 3 s timeout | 1,290 | 24.2 s | 0 | 42 |
| rate breaker, 1 s timeout | 45 | 1.61 s | 1,440 | 40 |
No outage, 5% ordinary errors: trips in 10 minutes
| Breaker | Calls / s | Wrong trips |
|---|---|---|
| rate breaker | 1 | 0 |
| count breaker | 1 | 1 |
| rate breaker | 50 | 0 |
| count breaker | 50 | 48 |
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 minutesWhat 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 bulkheads 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.