Hands-On Labs · Chaos Experiment Design Drill

Drill — Design a Chaos Experiment

This is not a lesson — it's one exercise, worked all the way through. Wavelength's checkout stack had a near-miss last month: a routine database failover happened to coincide with a 90-second spike in checkout errors that quietly resolved itself before anyone finished loading a dashboard, and nobody has ever deliberately tested what actually happens to checkout when the database's primary changes underneath it. Your job is to design — not run — the chaos experiment that would answer that question properly: a falsifiable hypothesis, a blast radius and a set of abort conditions decided before anyone touches a keyboard, and an exact specification of the evidence that would prove you wrong. Nothing on this page gets executed against a real system. The deliverable is a design a chaos-engineering review board would actually approve, and writing one properly is the entire skill this drill exists to build.

☺ Explain it like I'm 10

Imagine your school wants to test the fire alarm, but instead of just pulling it and watching everyone's face, someone has to write the plan first: which door do we expect the third-graders to use, how many seconds should that take, at what point does a teacher blow a whistle and stop the drill early because something's actually gone wrong, and what exactly would we see if the plan failed — kids at the wrong door, taking too long, someone left behind. Only once all of that is written down, agreed, and checked twice does anyone pull the alarm. This drill is writing that plan for a computer system's fire alarm. You never actually pull it.

🦝Your host for this drill: Rocky the Raccoon — eager to break things on purpose, and about to learn the difference between an experiment and a stunt.

The scenario: Wavelength's checkout stack, three tiers deep

☺ Like you're 10: A page you click, a program that does the work, and a filing cabinet that remembers the order — three tiers, and today's test targets the filing cabinet.

Wavelength's checkout flow is a plain three-tier design. checkout-web is the presentation tier: a stateless server-rendered frontend behind an ALB, autoscaled 12–20 instances across three AZs — nothing here holds state, and losing any single instance is already a well-tested, unremarkable event that happens routinely during normal deploys. checkout-svc is the application tier: 18 Go API pods, six per AZ, that validate the cart, reserve inventory, write the order record, and call out to Stripe to charge the card. orders-db is the data tier: Postgres 15, with automated failover managed by Patroni — one primary in us-east-1a, a synchronous replica in us-east-1b, and an asynchronous replica in us-east-1c — reached through a PgBouncer connection pool: three poolers, 200 max connections each, 600 total. Every one of checkout-svc's three sequential database calls per checkout attempt (validate cart, reserve inventory, write order) goes through that shared pool.

FactValue
Checkout completion rate, 30-day baseline99.92%
POST /checkout latency, baseline (p50 / p95 / p99)180ms / 620ms / 1.4s
Checkout attempts, peak (weekday 18:00–21:00 UTC)~1,400/min (23.3 req/s)
Checkout attempts, trough (03:00–05:00 UTC)~180/min (3.0 req/s)
checkout-svc's own SLO99.9% completion, rolling 30-day window
checkout-svcorders-db retry policy3 attempts, fixed 500ms delay between retries, no jitter, 2s per-attempt timeout
Last 6 planned Patroni switchovers (maintenance windows)failover time 18s–47s, average 29s
Last month's incidentunplanned failover during a patch window; checkout errors spiked to 6.5% for ~90s, self-resolved, never root-caused
checkout-web — presentation tier 12–20 instances · 3 AZs · behind an ALB checkout-svc — application tier 18 pods · 6 per AZ · validates cart, reserves inventory, writes order TODAY'S FAULT not tested today orders-db — data tier, Patroni-managed primary us-east-1a ⚡ patronictl switchover promotes sync replica us-east-1b becomes primary async replica us-east-1c unaffected, stays async Stripe payments API see "try it yourself" Scope for today's experiment: orders-db only. checkout-web and checkout-svc are observed, never faulted directly.

Nobody has deliberately failed orders-db's primary on purpose — not in staging with realistic load, and certainly not in production. The closest thing to a real signal is last month's near-miss noted in the table above: an unplanned failover during a routine patch window happened to coincide with a 90-second spike in checkout errors that self-resolved before anyone finished pulling up a dashboard. That is not evidence the system is fine. It is evidence that nobody knows yet, one way or the other — which is exactly the condition that makes this a worthwhile chaos experiment instead of a formality that confirms what everyone already believes.

What you're actually being asked to produce

☺ Like you're 10: Four things, written down, before anyone so much as opens a terminal.

Four deliverables, in order, are what this drill is grading — not a script, not a command, not a dashboard screenshot. (1) A falsifiable hypothesis with a named fault, a named scope, and a numeric steady-state threshold. (2) A blast radius, fixed in advance, that states exactly where this runs first and what has to hold true before it's allowed to run anywhere wider. (3) Abort conditions, also fixed in advance, naming the exact metric, threshold, and person with the authority to call it. (4) An exact specification of what evidence — which metric, which query, which numeric threshold — would count as proof the hypothesis is wrong, so that a maybe-it-held result can't quietly get scored as a pass.

⚠ Watch out

Nothing on this page gets executed. There is no cluster to install, no CLI to run, no command to copy and paste. The entire deliverable is a written design — the same kind of document a chaos-engineering review board would read and either approve or send back before anyone is authorized to touch production. If you find yourself wanting to actually try patronictl switchover against something, that instinct is correct engineering curiosity aimed at the wrong exercise; this one stops at the page.

Rocky's first draft — and everything a reviewer would send back

☺ Like you're 10: "Kill it and see" isn't a plan, even if the animal saying it is very enthusiastic.

Rocky drops this in the team's chaos-engineering channel on Monday morning, visibly proud of himself:

Plan: kill the orders-db primary in prod Wednesday afternoon
and see what happens to checkout. Should be fine, we have
replicas.

— Rocky

Read it against the four deliverables above and it fails all four, plus one thing it doesn't even realize it's ambiguous about:

None of that means Rocky's underlying instinct is wrong — targeting orders-db's primary is, in fact, the right fault to test today, for reasons the next section makes explicit. The instinct just needs the four missing pieces before it's an experiment instead of a threat.

Step 1 — choose the fault and the target tier

☺ Like you're 10: Test the thing nobody's sure about and that would actually hurt if it broke — not the thing you already know is fine.

Chaos experiments are worth running in proportion to two things: how much genuine uncertainty exists about a failure mode, and how bad it would be if that uncertainty resolved the wrong way. Killing a single checkout-web instance scores low on both — it happens routinely during ordinary deploys, the ALB already handles it invisibly, and nobody has any real doubt about the outcome. A controlled failover of orders-db's primary scores high on both: it's never been deliberately tested, the retry policy in the baseline table (fixed 500ms delay, no jitter) is exactly the kind of gap reliability patterns warns produces synchronized retry waves against a shared resource, and the one real data point available — last month's near-miss — is inconclusive rather than reassuring. That combination is what makes this the right target, not simply "it's the database, databases are scary."

The specific fault: a graceful, operator-triggered switchover — patronictl switchover --candidate <replica> — that hands the primary role from us-east-1a to the synchronous replica in us-east-1b. This deliberately matches the shape of last month's near-miss (a primary changing under load) rather than a hard kill, because the open question the near-miss raised is specifically about what checkout-svc does during and immediately after a primary handoff — not about how long Patroni takes to notice a crash, which is a real but separate question saved for later.

Step 2 — write a hypothesis that can actually fail

☺ Like you're 10: Name the exact number that has to stay true, or you haven't really made a claim.

A well-formed hypothesis has three parts, per chaos engineering: the fault, the scope, and a testable steady-state. Assembled from Step 1 and the baseline table:

◆ The hypothesis

Fault: a single patronictl switchover of orders-db's primary from us-east-1a to the synchronous replica in us-east-1b.
Scope: orders-db only — checkout-web and checkout-svc are observed, never faulted directly.
Steady-state: for a 10-minute window bracketing the switchover — starting 1 minute before it's triggered, through 9 minutes after — checkout-svc's POST /checkout completion rate, measured in rolling 30-second windows, stays at or above 99.5%, and p99 latency, measured in rolling 1-minute windows, stays under 5 seconds.

Those thresholds are deliberately looser than the 99.92% / 1.4s baseline — some real degradation during an active primary handoff is expected and acceptable. The hypothesis isn't "nothing changes," it's "the degradation stays bounded and the system recovers on its own." 99.5% and 5s are the line between "a database failover, working as designed" and "a database failover that took checkout down with it."

◆ Key idea — spending budget on purpose

checkout-svc carries its own SLO of 99.9% over a rolling 30-day (43,200-minute) window, giving it 43.2 minutes of error budget a month — the same formula worked out in full in the SLO & error-budget drill. If the hypothesis's pass threshold is exactly met — 0.5% of requests failing for the full 10-minute observation window — that spends 0.005 × 10 = 0.05 minutes, three seconds, of that 43.2-minute monthly budget. Deliberately spending three seconds of budget to find out whether the system actually survives a failover is a good trade. Finding out for free, during an unplanned production incident, the way last month's near-miss did, is not — it's the same three seconds of information, paid for at a much worse exchange rate.

Step 3 — set the blast radius before anyone opens a terminal

☺ Like you're 10: Start in the quiet room, not the crowded one — the math below is exactly why.

The blast-radius ladder from chaos engineering applies directly here, and the baseline table gives enough to justify each rung with arithmetic instead of intuition. The mechanism under suspicion is specific: because retries use a fixed 500ms delay with no jitter, requests in flight when the primary disappears will tend to time out and retry in near-lockstep — and every request that arrives during the failover window queues up behind it, since there's no primary to write to until the promotion completes.

Queued DB calls during the failover window
  ≈ checkout attempts/sec × 3 DB calls/attempt × failover duration (s)

Peak traffic (23.3 req/s), worst-case failover (47s):
  23.3 × 3 × 47  ≈ 3,290 queued calls   — 5.5× the 600-connection pool

Peak traffic (23.3 req/s), average failover (29s):
  23.3 × 3 × 29  ≈ 2,029 queued calls   — 3.4× the 600-connection pool

Trough traffic (3.0 req/s), worst-case failover (47s):
  3.0 × 3 × 47   ≈   423 queued calls   — under the 600-connection pool

Trough traffic (3.0 req/s), average failover (29s):
  3.0 × 3 × 29   ≈   261 queued calls   — well under the 600-connection pool

That's a back-of-the-envelope estimate, not a guarantee — it assumes queued calls arrive as fast as clients attempt them, and doesn't model how PgBouncer actually queues versus rejects under pressure. That imprecision is itself the argument for staging first, not a reason to skip it. But the direction is unambiguous: at peak, even the average historical failover time triples the pool's capacity; at trough, even the worst historical failover time stays under it. That's a real, numeric reason to run off-peak first, not just a vague "traffic is lower then."

  1. Stage A — staging, synthetic load. Replay production's peak traffic shape against a staging cluster with the same topology (3-node Patroni, same PgBouncer sizing), trigger the same switchover, and confirm the hypothesis's thresholds hold. No real users, no real money, unlimited retries at this stage.
  2. Stage B — production, off-peak, single switchover. Only after Stage A holds cleanly, more than once. Runs at a specific scheduled window — Tuesday 03:30 UTC, inside the historical trough — with a same-day change freeze on checkout-svc, the on-call engineer and orders-db's owning team both watching dashboards live, and the switchover triggered by hand, not on a timer.
  3. Stage C — production, peak traffic. Explicitly not part of this design. It's deferred to a later, separately-approved experiment, contingent on Stage B holding cleanly more than once — the same "expand scope only after confidence is earned at the smaller one" rule the blast-radius ladder exists to enforce, and the arithmetic above is exactly why peak isn't where this starts.

Step 4 — write the abort conditions before anyone opens a terminal

☺ Like you're 10: Once the fire alarm's pulled, you can't un-pull it — so most of the safety plan has to happen before you pull it.

Here's a nuance worth stating plainly: once patronictl switchover is triggered, it runs to completion on its own in roughly 20–45 seconds — there is no command that cancels a promotion already in progress. That means "abort conditions" for this specific fault live in two different places, not one:

PhaseConditionAction
Pre-flight gate
(checked in the 5 minutes before triggering)
Checkout completion rate ≥ 99.85% right nowAny one failing → do not trigger. Reschedule.
Replication lag on both replicas < 2s
All 3 PgBouncer poolers < 50% connection utilization
No active incident, no other change freeze conflict
On-call IC and orders-db's owning team both present and watching
Live phase
(from trigger through +10 minutes)
Completion rate < 95% for any continuous 30s windowStop observing, declare the hypothesis disproven, and execute the pre-agreed remediation below — not a fault cancellation, since none exists.
PgBouncer client queue > 100 connections waiting for > 15s
p99 latency > 8s for > 60s

Any responder watching the dashboards can call a live-phase breach; the on-call IC has final authority to declare the experiment over versus continuing to observe. Because there's no true "abort switch" once the switchover starts, the pre-agreed remediation has to be ready to fire the instant a threshold breaches — not improvised: scale PgBouncer's pool cap immediately, or feature-flag a fast-fail on non-critical checkout steps to shed load off the pool. That's a materially different plan from Step 5's second scenario below, where the fault is instantly reversible — the two "try it yourself" answers in the key contrast this deliberately.

Step 5 — specify exactly what would prove you wrong

☺ Like you're 10: Decide, before you look, exactly what "it broke" would look like on the screen — otherwise you'll talk yourself into "close enough."

The hypothesis from Step 2 is falsified if, at any point in the 10-minute observation window, either of these queries crosses its threshold:

# Falsifies the completion-rate half of the hypothesis
sum(rate(checkout_requests_total{status=~"2.."}[30s]))
  / sum(rate(checkout_requests_total[30s])) < 0.995

# Falsifies the latency half of the hypothesis
histogram_quantile(0.99, rate(checkout_duration_seconds_bucket[1m])) > 5

# Supporting signal — not the hypothesis itself, but explains WHY if either fires
pgbouncer_pools_cl_waiting > 0

The hypothesis holds only if neither query crosses its line for the full window — not "mostly stayed under," not "dipped once and recovered." A single 30-second window at 99.4% is a clean falsification, not a rounding error near 99.5%; the same discipline the SLO & error-budget drill insists on when a number looks close enough to wave through.

⚠ Watch out

Watching completion rate alone is a trap here. A retry that eventually succeeds after queueing behind 600 exhausted connections still counts as a "successful" request in a naive completion-rate query — it just took 9 seconds to get there. That's why the hypothesis has two thresholds, not one: a request that technically succeeded but blew through the 5-second p99 bound is exactly the failure mode this experiment exists to catch, and completion rate by itself would hide it completely.

The finished design, assembled

☺ Like you're 10: Same four pieces, now stapled into one document a reviewer could actually sign off on.

CHAOS EXPERIMENT DESIGN — orders-db primary failover
Owner: on-call SRE + orders-db team   Status: awaiting Stage B approval

FAULT      patronictl switchover, orders-db primary
           us-east-1a → sync replica us-east-1b

SCOPE      orders-db only. checkout-web / checkout-svc observed, not faulted.

HYPOTHESIS For 10 min bracketing the switchover (T-1min .. T+9min):
             checkout completion rate stays ≥ 99.5% (rolling 30s)
             AND p99 checkout latency stays < 5s (rolling 1min)

BLAST      Stage A: staging, synthetic peak-shaped load        — required first
RADIUS     Stage B: prod, Tue 03:30 UTC, single switchover      — this run
           Stage C: prod, peak traffic                          — NOT in scope

PRE-FLIGHT completion ≥ 99.85% · replica lag < 2s · pools < 50%
GATE       no active incident · IC + orders-db team present
           any gate fails → do not trigger, reschedule

LIVE-PHASE completion < 95% for 30s, OR queue > 100 for 15s,
BREACH     OR p99 > 8s for 60s
           → hypothesis disproven, execute remediation, no fault cancellation exists

EVIDENCE   falsified if either PromQL threshold in Step 5 crosses,
           at any point, for the full window — no partial credit

Try it yourself: the payments dependency

☺ Like you're 10: Same four steps, different target — and this time the fault has an actual off switch.

☆ Try it

The schematic above flagged one thing as explicitly out of scope: checkout-svc's call to Stripe has a 4-second timeout and no circuit breaker — a known gap, never deliberately tested. Design a second chaos experiment targeting that call. You cannot inject a fault into Stripe itself — it's a vendor you don't operate. Where does the fault actually get injected instead? Write the hypothesis, the blast radius, and the abort condition before checking the answer key. One hint worth sitting with: this fault type is reversible in a way Step 1's isn't — what does that change about what "abort" means here?

🎬 At the Reliability Watch
🦝

Rocky: Fine, fine — no straight kill Wednesday. But can I at least pick the day?

🐢

Timmy the Turtle: You can pick the day once the pre-flight gate is written down, not before. What replication lag stops you from triggering?

🦝

Rocky: ...I don't have a number for that yet.

🦥

Sol the Sloth: While you two argue, I worked out what your hypothesis actually spends. Three seconds of error budget if it lands exactly on the line. Cheap, if you write the line down first.

🦊

Foxy: And if the switchover's already running when something goes wrong — you can't un-pull that alarm. So what happens then?

🦝

Rocky: Pre-agreed remediation, fired the instant a threshold breaches. Not improvised at 3:31am.

🦉

Professor Owl: Now it's an experiment. An hour ago it was a threat with good intentions.

✓ Checkpoint

1. Using the two criteria this drill uses to prioritize experiments, why does orders-db's primary failover outrank killing a checkout-web instance as today's target? 2. State this drill's hypothesis's three parts and the exact numeric threshold attached to each. 3. Why doesn't a database failover have a true "abort" once triggered, and what two things does the safety plan consist of instead? 4. Walk through the arithmetic that makes the off-peak window survivable and the peak window unsafe — what's actually being compared to what? 5. For the "try it yourself" Stripe experiment: where does the fault actually get injected, what's a reasonable hypothesis, and how is its abort plan different from Step 1's?

Check your answers
  1. The two criteria are how much genuine uncertainty exists about the failure mode, and how bad it would be if that uncertainty resolved badly. A checkout-web instance loss happens routinely during ordinary deploys and the ALB already absorbs it invisibly — low uncertainty, low stakes. orders-db's primary failover has never been deliberately tested, sits behind a retry policy with no jitter (a known synchronized-retry risk per reliability patterns), and the only real data point — last month's near-miss — is inconclusive rather than reassuring. High uncertainty, high stakes.
  2. Fault: a single patronictl switchover of orders-db's primary from us-east-1a to the sync replica in us-east-1b. Scope: orders-db only. Steady-state: checkout completion rate ≥ 99.5% (rolling 30s) and p99 latency < 5 seconds (rolling 1min), for the full 10-minute window bracketing the switchover.
  3. Once triggered, a Patroni switchover runs to completion on its own in roughly 20–45 seconds — there's no command that cancels a promotion mid-flight. The safety plan is therefore split in two: a pre-flight go/no-go gate checked before triggering (replication lag, pool headroom, current error rate, staffing — any failure means don't trigger at all), and a pre-agreed remediation ready to fire the instant a live-phase threshold breaches after triggering, since the fault itself can't be called back.
  4. The comparison is queued DB calls (checkout attempts/sec × 3 DB calls/attempt × failover duration) against the PgBouncer pool's 600-connection cap. At peak traffic (23.3 req/s), even the average historical failover (29s) produces roughly 2,029 queued calls — 3.4× the cap. At trough traffic (3.0 req/s), even the worst-case historical failover (47s) produces roughly 423 queued calls — still under the cap. That's why the first production run is scheduled off-peak, not merely because traffic is lower in some vague sense.
  5. The fault gets injected at checkout-svc's own client boundary — a service-mesh fault-injection rule (an Envoy or Istio delay/abort filter) adding latency to outbound calls to Stripe's endpoint — never at Stripe itself, since it's a vendor Wavelength doesn't operate. A reasonable hypothesis: with 3.5 seconds of injected latency (just under the 4s client timeout) on 100% of calls to Stripe for a 5-minute window in staging, then a small percentage of production traffic, checkout requests that don't touch Stripe (e.g. plain cart validation) keep succeeding — testing whether the missing circuit breaker lets a slow Stripe call exhaust a shared resource pool and take down unrelated requests, the same bulkhead gap reliability patterns describes. Unlike Step 1's fault, this one has a genuine abort switch: deleting the fault-injection rule stops the injected latency within seconds, so the live-phase plan is simply "pull the rule," not a pre-agreed remediation for a fault that can't be recalled.

This drill designs the experiment; it doesn't run it. For the mechanics of actually executing a graduated chaos program — tooling, stakeholder buy-in, and the staging-to-production maturity path — see chaos engineering and chaos engineering at scale. The full version of this exercise, run against a real throwaway environment, is Capstone Part 6 — Chaos Engineer It. For the retry-and-pool reasoning this drill leaned on, see reliability patterns; for the SLO arithmetic behind the error-budget aside, see the SLO & error-budget drill.