Skip to content
About
Main content

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 , 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

With health checks every 2 s and 3 failures to mark a node down, how long after the kill does the error rate return below 1%?

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.

    kill-a-node-and-measure-the.py129 linesDownload
    View 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)
    
  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 kill-a-node-and-measure-the.py
    uv run kill-a-node-and-measure-the.py --help     # every knob
Knobs
Knob · defaultWhat 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

Client error rate around the kill (t = 0)Apple M2 · 8 cores · 8 GB
kill 1kill 2kill 3
Data table
Client error rate around the kill (t = 0)
Seconds after the killseriesErrors (%)
-5kill 10
-4.5kill 10
-4kill 10
-3.5kill 10
-3kill 10
-2.5kill 10
-2kill 10
-1.5kill 10
-1kill 10
-0.5kill 150
0kill 1100
0.5kill 1100
1kill 1100
1.5kill 1100
2kill 1100
2.5kill 1100
3kill 1100
3.5kill 1100
4kill 1100
4.5kill 151.5
5kill 11.5
5.5kill 10
6kill 10
6.5kill 10
7kill 10
7.5kill 10
8kill 10
8.5kill 10
9kill 10
9.5kill 10
10kill 10
10.5kill 10
11kill 10
11.5kill 10
12kill 10
12.5kill 10
13kill 10
13.5kill 10
14kill 10
14.5kill 10
15kill 10
15.5kill 10
16kill 10
16.5kill 10
17kill 10
17.5kill 10
18kill 10
18.5kill 10
19kill 10
19.5kill 10
20kill 10
20.5kill 10
21kill 10
21.5kill 10
22kill 10
22.5kill 10
23kill 10
23.5kill 10
24kill 10
24.5kill 10
25kill 10
25.5kill 10
26kill 10
26.5kill 10
27kill 10
27.5kill 10
28kill 10
28.5kill 10
-5kill 20
-4.5kill 20
-4kill 20
-3.5kill 20
-3kill 20
-2.5kill 20
-2kill 20
-1.5kill 20
-1kill 20
-0.5kill 250
0kill 2100
0.5kill 2100
1kill 2100
1.5kill 2100
2kill 2100
2.5kill 2100
3kill 2100
3.5kill 2100
4kill 2100
4.5kill 251
5kill 21
5.5kill 20
6kill 20
6.5kill 20
7kill 20
7.5kill 20
8kill 20
8.5kill 20
9kill 20
9.5kill 20
10kill 20
10.5kill 20
11kill 20
11.5kill 20
12kill 20
12.5kill 20
13kill 20
13.5kill 20
14kill 20
14.5kill 20
15kill 20
15.5kill 20
16kill 20
16.5kill 20
17kill 20
17.5kill 20
18kill 20
18.5kill 20
19kill 20
19.5kill 20
20kill 20
20.5kill 20
21kill 20
21.5kill 20
22kill 20
22.5kill 20
23kill 20
23.5kill 20
24kill 20
24.5kill 20
25kill 20
25.5kill 20
26kill 20
26.5kill 20
27kill 20
27.5kill 20
28kill 20
28.5kill 20
-5kill 30
-4.5kill 30
-4kill 30
-3.5kill 30
-3kill 30
-2.5kill 30
-2kill 30
-1.5kill 30
-1kill 30
-0.5kill 350
0kill 3100
0.5kill 3100
1kill 3100
1.5kill 3100
2kill 3100
2.5kill 3100
3kill 3100
3.5kill 3100
4kill 3100
4.5kill 351.5
5kill 31.5
5.5kill 30
6kill 30
6.5kill 30
7kill 30
7.5kill 30
8kill 30
8.5kill 30
9kill 30
9.5kill 30
10kill 30
10.5kill 30
11kill 30
11.5kill 30
12kill 30
12.5kill 30
13kill 30
13.5kill 30
14kill 30
14.5kill 30
15kill 30
15.5kill 30
16kill 30
16.5kill 30
17kill 30
17.5kill 30
18kill 30
18.5kill 30
19kill 30
19.5kill 30
20kill 30
20.5kill 30
21kill 30
21.5kill 30
22kill 30
22.5kill 30
23kill 30
23.5kill 30
24kill 30
24.5kill 30
25kill 30
25.5kill 30
26kill 30
26.5kill 30
27kill 30
27.5kill 30
28kill 30
28.5kill 30
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

Three kills

Three kills
KillBack below 1% afterFailed requestsHypothesis
16 s1Ksurvived
26 s1Ksurvived
36 s1Ksurvived
Output · uv run kill-a-node-and-measure-the.py
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.