Codelab: Run a Chaos Experiment
Goal: Your First Real Experiment
Nine lessons of resilience end the only way resilience can be proven: by breaking something on purpose and watching what happens. In the next fifteen minutes you will define what normal means for a real service, write a falsifiable hypothesis, inject a real failure into a real dependency, and discover a genuine weakness that no amount of reading could have told you was there. Then you'll fix it and prove the fix holds under the exact same failure.
The experiment is deliberately small, because Chaos Engineering was clear that small is the whole point. A tiny checkout service reads one value from Redis. Between them sits Toxiproxy, a proxy built for exactly this: it forwards traffic normally until you tell it, at runtime, to add latency or to stop answering, and it stops the moment you say so. That is your valve, and the valve is what makes this safe: a blast radius of one dependency and an abort that is one command away. Nothing here is simulated. Every command and every number below was run exactly as printed (Toxiproxy 2.12.0, hey 0.1.5, Redis 8.8.0). You already know every idea you're about to type. The point is to feel it.

The Setup: A Service, a Dependency, and a Valve
Three installs, all standard. On macOS with Homebrew (Linux: your package manager has all three, or build Toxiproxy with go install):
$ brew install toxiproxy hey
$ redis-server --version
Redis server v=8.8.0 (00000000)
$ toxiproxy-server --version
toxiproxy-server version 2.12.0Now the service. It is about fifteen lines and needs no framework and no npm install: it opens a raw socket to Redis, asks for one key, and returns it. Save it as app.js. Read the one comment near the bottom twice, because that missing line is the entire lesson:
// app.js — a checkout service that reads one key from Redis. ~15 lines, no framework.
const http = require('http'), net = require('net')
const REDIS = Number(process.env.REDIS_PORT || 26379) // point at the PROXY, not at Redis
function getColor() { // read one key over a raw socket
return new Promise((resolve, reject) => {
const sock = net.connect(REDIS, '127.0.0.1', () => sock.write('GET color\r\n'))
let buf = ''
sock.on('data', d => { buf += d
if (buf.startsWith('$')) { sock.end(); resolve(buf.split('\r\n')[1]) } })
sock.on('error', reject)
// NOTE: there is no timeout here. Hold that thought.
})
}
http.createServer(async (req, res) => {
try { const c = await getColor(); res.writeHead(200); res.end(c + '\n') }
catch (e) { res.writeHead(502); res.end('error\n') }
}).listen(4600, () => console.log('checkout-svc on :4600 -> redis :' + REDIS))The wiring is the important part, and it is the reason this is safe. The service will not talk to Redis directly. It will talk to Toxiproxy, and Toxiproxy talks to Redis. Start a real Redis on its own port, put the proxy in front of it, then point the service at the proxy:
# 1. a real dependency, on its own port (so we never touch anything else)
$ redis-server --port 6399 --save '' --daemonize yes
$ redis-cli -p 6399 set color blue
OK
# 2. the valve: start Toxiproxy, then create a proxy in front of Redis
$ toxiproxy-server &
... "Starting Toxiproxy" ... api listening on localhost:8474
$ toxiproxy-cli create -l 127.0.0.1:26379 -u 127.0.0.1:6399 redis
Created new proxy redis
$ toxiproxy-cli list
redis 127.0.0.1:26379 127.0.0.1:6399 enabled 0
# 3. point the service at the PROXY (:26379), not at Redis (:6399)
$ REDIS_PORT=26379 node app.js
checkout-svc on :4600 -> redis 127.0.0.1:26379
That middle hop is the whole trick. Every byte between the service and its dependency now flows through a valve you control, and everything you do to that valve you can undo instantly. This is the blast radius made literal: one dependency, one proxy, one command to heal. It is Latency Monkey from the last lesson, running on your laptop, with a hand on the handle.
Step 1: Define Steady State
You cannot tell that a system broke unless you first agreed on what working looks like, in a number. That number is the steady state, and the rule from Chaos Engineering is that it should be user-facing, not machine-facing: measure what a customer feels, not CPU. For this service, the customer feels two things — does the request succeed, and how long did it take. So put real load on it and write down normal. hey fires a burst of requests and reports the percentiles you met in Tail Latency: Living at p99:
$ curl -s http://127.0.0.1:4600/
blue
$ hey -n 2000 -c 50 http://127.0.0.1:4600/
Summary:
Requests/sec: 9662.6
Average: 0.0050 secs
Latency distribution:
50% in 0.0049 secs
99% in 0.0206 secs
Status code distribution:
[200] 2000 responsesThere is your normal, in three numbers you will hold the rest of the experiment against: 100 percent of requests succeed (all two thousand came back 200), p50 about 5 milliseconds, p99 about 20 milliseconds. Write them on a sticky note. Everything that follows is measured as a distance from this line.
Step 2: Write the Hypothesis (and the Stop Condition)
Now the sentence that separates an experiment from vandalism. A hypothesis is a falsifiable prediction about the steady state under a specific failure, written before you inject it:
If Redis slows down by 300 milliseconds, checkout still succeeds for 100 percent of requests, because a little dependency latency should only make us a little slower.
Say it out loud, because you are about to try to prove yourself wrong. That is the job: not to confirm the system works, but to try to disprove the hypothesis and see what you learn when it survives, or when it doesn't. And before you touch anything, decide your stop condition and your abort, in advance, the way the last lesson insisted: the blast radius is this one proxy, and the abort is a single command, toxiproxy-cli toxic remove, which you will keep in a terminal ready to run. If anything looks worse than you can stomach, you pull that trigger and the failure is gone. Decided now, in cold blood, so you never have to decide it in a panic.
Step 3: Inject a Little (and Watch It Hold)
Turn the valve a quarter turn. Add 300 milliseconds of latency to every byte coming back from Redis, then measure against your sticky note:
$ toxiproxy-cli toxic add -t latency -a latency=300 redis
Added downstream latency toxic 'latency_downstream' on proxy 'redis'
$ hey -n 200 -c 20 http://127.0.0.1:4600/
Average: 0.3063 secs
50% in 0.3059 secs
99% in 0.3163 secs
Status code distribution:
[200] 200 responsesRead it carefully, because this is the boring result that matters. Latency went exactly where you pushed it — p50 and p99 both parked around 305 milliseconds, three hundred slower than baseline, precisely the toxic you added. But success stayed at 100 percent. Every request came back 200. Your hypothesis survived its first contact with reality: a slow dependency made the service slower, and nothing else. This is the Tail Latency lesson in one line of output, and if you stopped here you would walk away with false confidence. A real experiment does not stop at the first result that flatters it. It escalates until something gives.
Break It: Escalate Until Something Gives
Slow is the gentle failure. The cruel one, the one that actually takes services down, is the dependency that does not slow or die but simply stops answering — the gray failure from the start of this section, the half-open socket that will never send another byte. Toxiproxy has a toxic for exactly that. Swap the latency for a timeout toxic with timeout=0, which means the connection stays open and the data never comes:
$ toxiproxy-cli toxic remove -n latency_downstream redis
Removed toxic 'latency_downstream' on proxy 'redis'
$ toxiproxy-cli toxic add -t timeout -a timeout=0 redis
Added downstream timeout toxic 'timeout_downstream' on proxy 'redis'
# one request, by hand, with a 4-second patience limit:
$ curl -s -m 4 http://127.0.0.1:4600/
curl: (28) Operation timed out after 4001 millisecondsThe request never came back. curl gave up after four seconds; the service itself would have waited forever, because look again at app.js — there is no timeout on that socket. Now measure it under load and watch the steady state fall through the floor:
$ hey -z 8s -c 20 -t 3 http://127.0.0.1:4600/
Requests/sec: 6.66
Status code distribution:
[no successful responses]
Error distribution:
[60] Get "http://127.0.0.1:4600/": context deadline exceeded
(Client.Timeout exceeded while awaiting headers)
Sit with this, because it is the most important minute in the section. Redis is not down. One connection is merely slow to the point of silence — and your service went from 100 percent success to zero. Not slow. Not degraded. Completely dark, every request hanging on a socket that will never answer, the service's own capacity filling with frozen requests that each hold their resources and wait. That is the whole of Cascading Failures reproduced on your laptop in one command: a single slow dependency, with no timeout to stop it, becoming a total outage. Your hypothesis is disproven, and this is the good outcome — you found the missing timeout in a lab on a Tuesday, not in production at 3 a.m. The finding is not "the system is broken." The finding is a specific, fixable gap: this service has no client timeout.
The Stop Condition: One Command Back to Safe
Before you fix anything, prove to yourself that the abort works, because that is the promise that let you run this without fear. Pull the trigger you loaded in Step 2:
$ toxiproxy-cli toxic remove -n timeout_downstream redis
Removed toxic 'timeout_downstream' on proxy 'redis'
$ curl -s http://127.0.0.1:4600/
blue
$ hey -n 1000 -c 50 http://127.0.0.1:4600/
Average: 0.0053 secs
[200] 1000 responsesInstant. The moment the toxic was gone the service was back to its 5-millisecond, 100-percent self, as if nothing had happened, because from Redis's point of view nothing did. This is what the blast radius bought you: a failure you could turn on to learn from, and turn off the instant you were done. A controlled burn, put out with one bucket. Now go close the gap you found.
Close the Gap: and Prove It
The weakness was a socket with no deadline, so give it one. Two lines change in app.js: a 200-millisecond timeout on the Redis call, and, instead of erroring when it fires, a fallback to the last value we successfully read. This is Timeout Hierarchies and the graceful degradation of Load Shedding & Graceful Degradation, together, in the smallest possible form:
// app.js — two lines change. A client timeout, and a fallback instead of an error.
let lastGood = 'blue' // remember the last value we read
const sock = net.connect(REDIS, '127.0.0.1', () => sock.write('GET color\r\n'))
sock.setTimeout(200, () => { sock.destroy(); reject(new Error('redis timeout')) }) // <- ADD
try { const c = await getColor(); lastGood = c; res.writeHead(200); res.end(c + '\n') }
catch (e) { res.writeHead(200, {'X-Degraded': 'true'}); res.end(lastGood + ' (cached)\n') } // <- was 502Restart the service, warm it once so it has a value to fall back to, and then run the exact same experiment that just took it down — same toxic, same load. This is the step most people skip and the only one that proves anything:
$ toxiproxy-cli toxic add -t timeout -a timeout=0 redis
Added downstream timeout toxic 'timeout_downstream' on proxy 'redis'
$ curl -s -D - http://127.0.0.1:4600/ | grep -iE 'HTTP|Degraded|cached'
HTTP/1.1 200 OK
X-Degraded: true
blue (cached)
$ hey -n 300 -c 20 -t 3 http://127.0.0.1:4600/
Average: 0.2052 secs
99% in 0.2086 secs
Status code distribution:
[200] 300 responses
The identical failure that produced zero successful responses now produces three hundred out of three hundred. The dependency is just as dead as before; the difference is that the service no longer waits on it forever. It waits 200 milliseconds, gives up, and serves the last-known answer with a header that admits it is stale. Latency is capped at the timeout, success is back to 100 percent, and a customer gets a slightly old color instead of a spinner that never ends. That is the entire argument of this section, earned rather than asserted: you injected a real failure, watched a real defense hold, and now you know — not hope — that a slow Redis cannot take checkout down. A hypothesis, disproven, fixed, and re-proven, in fifteen minutes.
Clean Up
$ toxiproxy-cli toxic remove -n timeout_downstream redis # heal the valve
$ redis-cli -p 6399 shutdown nosave # stop Redis
$ kill %1 # stop toxiproxy-server
$ pkill -f 'node app.js' # stop the serviceWhat Just Happened: Every Toxic Was a Lesson
Scroll back through your terminal. You did not run a demo; you ran the whole section, wearing work clothes.
- The steady state, in a percentile — success rate and p99, measured under load before you touched anything: the user-facing metric from Chaos Engineering, and the percentiles from Tail Latency: Living at p99.
- The proxy as a blast radius — one dependency on a leash, every failure reversible with one command: the small, contained, abortable experiment the whole discipline is built on.
- The latency toxic that held — 300 milliseconds of slowness that only made you slower: a dependency behaving badly within tolerance, and a hypothesis surviving.
- The timeout toxic that didn't — a dependency that stopped answering, with no client timeout to stop it, turning one slow socket into a dark service: Cascading Failures, live, and the exact gap Timeout Hierarchies exists to close.
- The one-command abort — the stop condition you loaded before you fired, proving the burn was always controlled.
- The fix that held — a timeout plus a cached fallback, converting an outage into a barely-noticed blip: Timeout Hierarchies and Load Shedding & Graceful Degradation doing their jobs under a failure you could see.
Change one thing and run it again, because that is what the practice actually is. Add a retries loop with no budget and watch a Retries, Budgets & Retry Storms storm build. Wrap the call in a breaker and watch Circuit Breakers trip. Give the fallback its own bounded pool and you've drawn a Bulkhead. The lab is the same; only the toxic changes.
Key Takeaways
- An experiment needs a number, a sentence, and an escape hatch. A steady state you can measure, a falsifiable hypothesis written before you inject, and a stop condition decided in advance. Skip any one and you have a demo, or an incident.
- Slow is not the failure that kills you. Silent is. The dependency that stops answering, not the one that dies, is what fills your service with frozen requests — and the only thing standing between one silent socket and a total outage is a timeout you set on purpose.
- A defense is a hypothesis until you inject the failure. You did not read that the fix worked; you watched zero-out-of-many become many-out-of-many under the identical toxic. That is the difference chaos engineering exists to create.
- The blast radius is what makes it safe, not brave. A proxy you can heal with one command let you break production-shaped things without breaking production. Small, reversible, on purpose.
That is the section, cashed at a keyboard: every defense from Timeout Hierarchies through Chaos Engineering, proven by breaking something and watching it hold. What remains is to prove you own it — ten decisions drawn from the whole of resilience and overload control, in Checkpoint: Resilience & Overload Control.