Kill a node and measure the recovery
Break it on purposeNeeds Python
Problem
A failover's real duration is set by the slowest detector in the chain: health-check intervals, failure thresholds, client timeouts. It is easy to state and rarely measured from the client's side. A chaos experiment makes it measurable: write the hypothesis before the run, set an abort condition, apply steady load, kill the node, and record the time until the client's error rate is back below the threshold.
The script starts three instances of a small service (their own processes) behind a health-checked proxy that sends traffic to the first healthy instance and fails over to the next, with common load-balancer defaults (check every 2 s, down after 3 failures). Hypothesis, written first: "after the primary is killed, the error rate returns below 1% within 10 seconds". Abort condition: errors above 50% for 60 s. A steady client (200 requests per second, measured from the intended send time) runs while the primary is killed with kill -9. Repeat three times and report the range.
The question. How long after the kill does the client's error rate return below 1%, and does the hypothesis survive?
Predict
Expect a first measured value far above your guess, because of client retry timeouts or health-check intervals.
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
kill-a-node-and-measure-the.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Kill a node and measure the recovery. Three instances of a small service (their own processes) sit behind a health-checked proxy that sends traffic to the first healthy instance (the primary) and fails over to the next. Hypothesis, written first: "after the primary is killed, the error rate returns below 1% within 10 seconds". Abort condition: errors above 50% for 60 s. A steady open-loop client measures from the intended send time while the primary is killed with SIGKILL, three times. Proxy settings mirror common load-balancer defaults (check every 2 s, down after 3 failures). """ import argparse, asyncio, json, os, signal, subprocess, sys, time p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--rate", type=float, default=200, help="Client requests per second.") p.add_argument("--timeout", type=float, default=2.0, help="Client timeout per request (s).") p.add_argument("--check-interval", type=float, default=2.0, help="Proxy health-check interval (s).") p.add_argument("--fall", type=int, default=3, help="Failed checks before the proxy marks an instance down.") p.add_argument("--hypothesis", type=float, default=10, help="Hypothesised recovery time (s).") p.add_argument("--repeats", type=int, default=3, help="Kills to run.") p.add_argument("--port", type=int, default=58200, help="First local port to use.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() BACKEND = r""" import asyncio, sys async def h(r, w): while await r.readline(): w.write(b"OK\n"); await w.drain() async def main(): s = await asyncio.start_server(h, "127.0.0.1", int(sys.argv[1])); print("ready", flush=True) async with s: await s.serve_forever() asyncio.run(main()) """ PROXY = r""" import asyncio, sys port, backends, inter, fall = int(sys.argv[1]), [int(x) for x in sys.argv[2].split(",")], float(sys.argv[3]), int(sys.argv[4]) up = {b: True for b in backends}; fails = {b: 0 for b in backends} async def ping(b): try: r, w = await asyncio.wait_for(asyncio.open_connection("127.0.0.1", b), 1) w.write(b"PING\n"); await asyncio.wait_for(r.readline(), 1); w.close(); return True except Exception: return False async def checks(): while True: for b in backends: if await ping(b): fails[b] = 0; up[b] = True else: fails[b] += 1 if fails[b] >= fall: up[b] = False await asyncio.sleep(inter) async def h(r, w): while await r.readline(): target = next((b for b in backends if up[b]), None) try: br, bw = await asyncio.open_connection("127.0.0.1", target) bw.write(b"GET\n"); reply = await br.readline(); bw.close() w.write(reply or b"ERR\n") except Exception: w.write(b"ERR\n") await w.drain() async def main(): asyncio.ensure_future(checks()) s = await asyncio.start_server(h, "127.0.0.1", port); print("ready", flush=True) async with s: await s.serve_forever() asyncio.run(main()) """ def start(code, *a): pr = subprocess.Popen([sys.executable, "-c", code, *map(str, a)], stdout=subprocess.PIPE, text=True) pr.stdout.readline(); return pr async def client(proxy_port, kill_at, total, primary): t0 = time.monotonic(); results = [] async def one(intended): ok = False try: r, w = await asyncio.wait_for(asyncio.open_connection("127.0.0.1", proxy_port), args.timeout) w.write(b"GET\n"); ok = (await asyncio.wait_for(r.readline(), max(0.01, intended + args.timeout - time.monotonic()))) == b"OK\n" w.close() except Exception: ok = False results.append((intended - t0, ok, time.monotonic() - intended)) tasks, killed, i = [], False, 0 while (intended := t0 + i / args.rate) < t0 + total: await asyncio.sleep(max(0, intended - time.monotonic())) if not killed and intended - t0 >= kill_at: os.kill(primary.pid, signal.SIGKILL); killed = True tasks.append(asyncio.ensure_future(one(intended))); i += 1 await asyncio.gather(*tasks); return results def analyse(results, kill_at): """Recovery = first second after the kill from which every 1 s window stays below 1% errors.""" buckets = {} for t, ok, _ in results: buckets.setdefault(int(t * 10), []).append(ok) def err(start): # error share in the 1 s window starting at tenth `start` xs = [ok for k in range(start, start + 10) for ok in buckets.get(k, [])] return 1 - sum(xs) / len(xs) if xs else 0 last = max(buckets); k0 = int(kill_at * 10); bad = [k for k in range(k0, last - 9) if err(k) >= 0.01] recovered = (max(bad) + 10) / 10 - kill_at if bad else 0.0 peak = max((err(k) for k in range(k0, last - 9)), default=0) return recovered, peak, [{"t": k / 10 - kill_at, "error_pct": err(k) * 100} for k in range(0, last - 9, 5)] rows, timeline = [], [] for rep in range(args.repeats): base = args.port + rep * 10; ports = [base + 1, base + 2, base + 3] backends = [start(BACKEND, pt) for pt in ports] proxy = start(PROXY, base, ",".join(map(str, ports)), args.check_interval, args.fall) try: res = asyncio.run(client(base, 5, 5 + 30, backends[0])) finally: for pr in backends + [proxy]: pr.kill(); pr.wait() rec, peak, tl = analyse(res, 5) aborted = sum(1 for t, ok, _ in res if t > 5 and not ok) / max(1, sum(1 for t, *_ in res if t > 5)) > 0.5 rows.append({"kill": rep + 1, "recovery_s": rec, "peak_error_pct": peak * 100, "failed_requests": sum(1 for _, ok, _ in res if not ok), "hypothesis": "survived" if rec <= args.hypothesis else "refuted", "aborted": "yes" if aborted else "no"}) timeline += [{"kill": f"kill {rep + 1}", **x} for x in tl] print(f"kill {rep + 1}: errors back below 1% after {rec:.1f} s (peak {peak:.0%}, {rows[-1]['failed_requests']} failed requests)") rs = [r["recovery_s"] for r in rows] summary = {"min_s": min(rs), "max_s": max(rs), "hypothesis_s": args.hypothesis, "detect_floor_s": args.check_interval * (args.fall - 1), "detect_ceiling_s": args.check_interval * args.fall} print(f"hypothesis 'below 1% within {args.hypothesis:g} s': recovery ranged {min(rs):.1f}-{max(rs):.1f} s " f"(health checks alone need {summary['detect_floor_s']:g}-{summary['detect_ceiling_s']:g} s to mark the primary down)") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"kills": rows, "timeline": timeline}}, 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 kill-a-node-and-measure-the.py uv run kill-a-node-and-measure-the.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --rate200 | Client requests per second. |
| --timeout2 | Client timeout per request (s). |
| --check-interval2 | Proxy health-check interval (s). |
| --fall3 | Failed checks before the proxy marks an instance down. |
| --hypothesis10 | Hypothesised recovery time (s). |
| --repeats3 | Kills to run. |
| --port58,200 | First local port to use. |
Our run
Data table
| Seconds after the kill | series | Errors (%) |
|---|---|---|
| -5 | kill 1 | 0 |
| -4.5 | kill 1 | 0 |
| -4 | kill 1 | 0 |
| -3.5 | kill 1 | 0 |
| -3 | kill 1 | 0 |
| -2.5 | kill 1 | 0 |
| -2 | kill 1 | 0 |
| -1.5 | kill 1 | 0 |
| -1 | kill 1 | 0 |
| -0.5 | kill 1 | 50 |
| 0 | kill 1 | 100 |
| 0.5 | kill 1 | 100 |
| 1 | kill 1 | 100 |
| 1.5 | kill 1 | 100 |
| 2 | kill 1 | 100 |
| 2.5 | kill 1 | 100 |
| 3 | kill 1 | 100 |
| 3.5 | kill 1 | 100 |
| 4 | kill 1 | 100 |
| 4.5 | kill 1 | 51.5 |
| 5 | kill 1 | 1.5 |
| 5.5 | kill 1 | 0 |
| 6 | kill 1 | 0 |
| 6.5 | kill 1 | 0 |
| 7 | kill 1 | 0 |
| 7.5 | kill 1 | 0 |
| 8 | kill 1 | 0 |
| 8.5 | kill 1 | 0 |
| 9 | kill 1 | 0 |
| 9.5 | kill 1 | 0 |
| 10 | kill 1 | 0 |
| 10.5 | kill 1 | 0 |
| 11 | kill 1 | 0 |
| 11.5 | kill 1 | 0 |
| 12 | kill 1 | 0 |
| 12.5 | kill 1 | 0 |
| 13 | kill 1 | 0 |
| 13.5 | kill 1 | 0 |
| 14 | kill 1 | 0 |
| 14.5 | kill 1 | 0 |
| 15 | kill 1 | 0 |
| 15.5 | kill 1 | 0 |
| 16 | kill 1 | 0 |
| 16.5 | kill 1 | 0 |
| 17 | kill 1 | 0 |
| 17.5 | kill 1 | 0 |
| 18 | kill 1 | 0 |
| 18.5 | kill 1 | 0 |
| 19 | kill 1 | 0 |
| 19.5 | kill 1 | 0 |
| 20 | kill 1 | 0 |
| 20.5 | kill 1 | 0 |
| 21 | kill 1 | 0 |
| 21.5 | kill 1 | 0 |
| 22 | kill 1 | 0 |
| 22.5 | kill 1 | 0 |
| 23 | kill 1 | 0 |
| 23.5 | kill 1 | 0 |
| 24 | kill 1 | 0 |
| 24.5 | kill 1 | 0 |
| 25 | kill 1 | 0 |
| 25.5 | kill 1 | 0 |
| 26 | kill 1 | 0 |
| 26.5 | kill 1 | 0 |
| 27 | kill 1 | 0 |
| 27.5 | kill 1 | 0 |
| 28 | kill 1 | 0 |
| 28.5 | kill 1 | 0 |
| -5 | kill 2 | 0 |
| -4.5 | kill 2 | 0 |
| -4 | kill 2 | 0 |
| -3.5 | kill 2 | 0 |
| -3 | kill 2 | 0 |
| -2.5 | kill 2 | 0 |
| -2 | kill 2 | 0 |
| -1.5 | kill 2 | 0 |
| -1 | kill 2 | 0 |
| -0.5 | kill 2 | 50 |
| 0 | kill 2 | 100 |
| 0.5 | kill 2 | 100 |
| 1 | kill 2 | 100 |
| 1.5 | kill 2 | 100 |
| 2 | kill 2 | 100 |
| 2.5 | kill 2 | 100 |
| 3 | kill 2 | 100 |
| 3.5 | kill 2 | 100 |
| 4 | kill 2 | 100 |
| 4.5 | kill 2 | 51 |
| 5 | kill 2 | 1 |
| 5.5 | kill 2 | 0 |
| 6 | kill 2 | 0 |
| 6.5 | kill 2 | 0 |
| 7 | kill 2 | 0 |
| 7.5 | kill 2 | 0 |
| 8 | kill 2 | 0 |
| 8.5 | kill 2 | 0 |
| 9 | kill 2 | 0 |
| 9.5 | kill 2 | 0 |
| 10 | kill 2 | 0 |
| 10.5 | kill 2 | 0 |
| 11 | kill 2 | 0 |
| 11.5 | kill 2 | 0 |
| 12 | kill 2 | 0 |
| 12.5 | kill 2 | 0 |
| 13 | kill 2 | 0 |
| 13.5 | kill 2 | 0 |
| 14 | kill 2 | 0 |
| 14.5 | kill 2 | 0 |
| 15 | kill 2 | 0 |
| 15.5 | kill 2 | 0 |
| 16 | kill 2 | 0 |
| 16.5 | kill 2 | 0 |
| 17 | kill 2 | 0 |
| 17.5 | kill 2 | 0 |
| 18 | kill 2 | 0 |
| 18.5 | kill 2 | 0 |
| 19 | kill 2 | 0 |
| 19.5 | kill 2 | 0 |
| 20 | kill 2 | 0 |
| 20.5 | kill 2 | 0 |
| 21 | kill 2 | 0 |
| 21.5 | kill 2 | 0 |
| 22 | kill 2 | 0 |
| 22.5 | kill 2 | 0 |
| 23 | kill 2 | 0 |
| 23.5 | kill 2 | 0 |
| 24 | kill 2 | 0 |
| 24.5 | kill 2 | 0 |
| 25 | kill 2 | 0 |
| 25.5 | kill 2 | 0 |
| 26 | kill 2 | 0 |
| 26.5 | kill 2 | 0 |
| 27 | kill 2 | 0 |
| 27.5 | kill 2 | 0 |
| 28 | kill 2 | 0 |
| 28.5 | kill 2 | 0 |
| -5 | kill 3 | 0 |
| -4.5 | kill 3 | 0 |
| -4 | kill 3 | 0 |
| -3.5 | kill 3 | 0 |
| -3 | kill 3 | 0 |
| -2.5 | kill 3 | 0 |
| -2 | kill 3 | 0 |
| -1.5 | kill 3 | 0 |
| -1 | kill 3 | 0 |
| -0.5 | kill 3 | 50 |
| 0 | kill 3 | 100 |
| 0.5 | kill 3 | 100 |
| 1 | kill 3 | 100 |
| 1.5 | kill 3 | 100 |
| 2 | kill 3 | 100 |
| 2.5 | kill 3 | 100 |
| 3 | kill 3 | 100 |
| 3.5 | kill 3 | 100 |
| 4 | kill 3 | 100 |
| 4.5 | kill 3 | 51.5 |
| 5 | kill 3 | 1.5 |
| 5.5 | kill 3 | 0 |
| 6 | kill 3 | 0 |
| 6.5 | kill 3 | 0 |
| 7 | kill 3 | 0 |
| 7.5 | kill 3 | 0 |
| 8 | kill 3 | 0 |
| 8.5 | kill 3 | 0 |
| 9 | kill 3 | 0 |
| 9.5 | kill 3 | 0 |
| 10 | kill 3 | 0 |
| 10.5 | kill 3 | 0 |
| 11 | kill 3 | 0 |
| 11.5 | kill 3 | 0 |
| 12 | kill 3 | 0 |
| 12.5 | kill 3 | 0 |
| 13 | kill 3 | 0 |
| 13.5 | kill 3 | 0 |
| 14 | kill 3 | 0 |
| 14.5 | kill 3 | 0 |
| 15 | kill 3 | 0 |
| 15.5 | kill 3 | 0 |
| 16 | kill 3 | 0 |
| 16.5 | kill 3 | 0 |
| 17 | kill 3 | 0 |
| 17.5 | kill 3 | 0 |
| 18 | kill 3 | 0 |
| 18.5 | kill 3 | 0 |
| 19 | kill 3 | 0 |
| 19.5 | kill 3 | 0 |
| 20 | kill 3 | 0 |
| 20.5 | kill 3 | 0 |
| 21 | kill 3 | 0 |
| 21.5 | kill 3 | 0 |
| 22 | kill 3 | 0 |
| 22.5 | kill 3 | 0 |
| 23 | kill 3 | 0 |
| 23.5 | kill 3 | 0 |
| 24 | kill 3 | 0 |
| 24.5 | kill 3 | 0 |
| 25 | kill 3 | 0 |
| 25.5 | kill 3 | 0 |
| 26 | kill 3 | 0 |
| 26.5 | kill 3 | 0 |
| 27 | kill 3 | 0 |
| 27.5 | kill 3 | 0 |
| 28 | kill 3 | 0 |
| 28.5 | kill 3 | 0 |
Three kills
| Kill | Back below 1% after | Failed requests | Hypothesis |
|---|---|---|---|
| 1 | 6 s | 1K | survived |
| 2 | 6 s | 1K | survived |
| 3 | 6 s | 1K | survived |
kill 1: errors back below 1% after 6.0 s (peak 100%, 1003 failed requests)
kill 2: errors back below 1% after 6.0 s (peak 100%, 1002 failed requests)
kill 3: errors back below 1% after 6.0 s (peak 100%, 1003 failed requests)
hypothesis 'below 1% within 10 s': recovery ranged 6.0-6.0 s (health checks alone need 4-6 s to mark the primary down)What we found
Every kill recovered after 6 s, with 1K failed requests: the hypothesis survived (survived). The recovery time was exactly the health checker's: it needs 4 to 6 s of failed checks to mark the primary down, and until then every request went to the dead instance. It was not "far above the guess" here, because this client has no retries or long timeouts of its own; in a real stack those add to the detector's time.
Read it
Recovery time is a sum of timers, and the client sees all of them. Write the hypothesis first so that the result can refute it, measure from the client side with intended send times, and repeat: one kill is an anecdote. When the number is too high, the detector's interval and threshold are where to look first.