Testing Failure
The dependency table of Topic 42 says what Stagedoor does when Redis is gone, when the replica is gone, when Payrail is down, slow or failing 5 percent of its calls, and when the primary is unreachable. A table that is never exercised is a hope. Failure tests inject each row's condition, a Redis that refuses connections, a Postgres that stops answering, a Payrail that returns 503 on 5 percent of calls, and assert the row: the seat map still loads, the checkout returns 503 with Retry-After, the order is pending, the breaker opened, the metric moved. They are integration tests with the failure scripted, and the ones that fail on the first run are the ones worth having written.
Marek wrote the table in Chapter 7 and the tests three weeks later, and 3 of the 7 failed on their first run. The Redis-down test found that the in-process fallback limiter was never wired to the seat-map handler. The Payrail-slow test found that the handler returned in 3.4 seconds with the right response and left the hold unextended. The primary-paused test found that readiness kept returning 200 for 40 seconds after the database stopped answering, because the pool check had no timeout of its own. None of the three was a bug the functional suite could see, because the functional suite never makes anything fail.
One Test per Row of the Table
Seven tests, one per row, and each one has the same shape: set the condition, run the requests the row says are affected, assert the client-visible result and the internal one. The internal assertion is the half that most teams skip, and it is the half that catches the quiet failures: the log line with the dependency's name, the metric that records the fallback, the breaker's state. A test that asserts only the status code proves that an error was returned, not that the right thing happened before it.
| Row | Injected by | Client sees | Internal assertion |
|---|---|---|---|
redis-01 gone | Redis container stopped | seat map 200 from the primary; the 101st request in a second gets 429 with Retry-After: 1; browser sessions 401 | fallback counter incremented; log line names redis; outbox rows accumulate |
pg-replica-a gone | replica URL pointed at a closed port | reports and the event list return 200, unchanged | reads-on-primary counter incremented; alert text names the replica |
| Payrail down | fake.next raises for 20 calls | POST /orders returns 503 with Retry-After | order pending, hold extended by 10 minutes, no charge, breaker open |
| Payrail slow | fake sleeps 4 seconds | 504 in under 3.5 seconds | order pending, hold extended, one charge attempted |
| Payrail 5 percent | fake.fail_rate = 0.05 | no errors across 200 checkouts | breaker closed; retries succeeded; 10 retry log lines |
pg-primary gone | Postgres container paused | writes 503; replica reads 200 | /readyz 503 naming the pool within 1 second; /healthz 200 |
| the stream gone | Redis container stopped, worker side | orders confirmed; tickets delayed | outbox oldest-unpublished age climbing; relay retrying every 100 ms |
The demonstrated row is the first. With the Redis container stopped, the test sends 120 seat-map requests inside one second for event 8812 and asserts three things: the first 100 return 200 with the current map, the remaining 20 return 429 with a Retry-After of 1 second, and the primary's statement counter shows at most 100 seat-map queries, which is the whole point of the row. Then it asserts the internal half: the fallback counter reads 100, and a log line at warning level names redis-01 and the word fallback. That is the in-process limiter of Topic 49 being tested at its number, and it is the test that found the limiter unwired.
Injecting the Condition
The injection is always at the boundary, never by patching a function inside the service. For the fakes, the scripted controls of Topic 65: fake.next, fake.fail_rate, a fake that sleeps. For the real Postgres in its container, two choices with different meanings. Pausing the container, which Testcontainers exposes through the underlying Docker container's pause, freezes every process in it, so connections stay open and every statement hangs until the timeout: that is "the database stopped answering," the condition Topic 37's timeouts exist for, and the cleanest way to produce it. Setting statement_timeout low on the test's session produces "the query was too slow" instead, which is a different row with a different error. For Redis, a wrong port in the config produces a refused connection in a millisecond; stopping the container produces the same refusal with more realism and 2 seconds more setup; both are honest.
async def test_payrail_down_degrades_checkout(app_client, svc, fake_payrail, hold): fake_payrail.next_n(PayrailTimeout(), count=20) # enough failures to open the breaker: 50% of 20 for _ in range(10): # 10 checkouts, 2 attempts each, all time out await app_client.post("/orders", json=checkout_for(hold), headers=idem_key()) resp = await app_client.post("/orders", json=checkout_for(hold), headers=idem_key()) assert resp.status_code == 503 # 1. the client-visible half assert 0 < int(resp.headers["Retry-After"]) <= 30 # 2. a true cooldown, not a guess order = await svc.orders.get(resp.json()["order_id"]) assert order.status == "pending" # 3. not failed, not paid assert (await svc.holds.get(hold.id)).expires_at == hold.expires_at + timedelta(minutes=10) # 4. hold extended assert fake_payrail.charges == {} # 5a. nothing charged while the breaker was open assert metrics.gauge("breaker_open", dep="payrail") == 1 # 5b. the internal signal moved
The test scripts 20 consecutive timeouts on the fake, then sends 10 checkouts through the real HTTP client to the real app, each of which times out on both attempts, which is 20 failed calls and enough to open the breaker at 50 percent of a 20-call window. The eleventh checkout is the one under test. Five assertions follow, and only the first is the status code. The Retry-After is a number inside the breaker's 30-second cooldown, not a constant somebody typed. The order is pending. The hold's expiry moved by exactly 10 minutes. Nothing was charged, and the breaker's gauge reads open. Remove the hold extension from the handler and the fourth line fails; make the handler mark the order failed on a timeout and the third does. A test with only the first assertion would pass both of those bugs.
Asserting the Degrade, Not Just the Error
"Returns 503" is half an assertion, and it is the half that has been wrong in the least interesting way. The other half is the state: the order row, the hold, the payments table, the header, the breaker's gauge. On the night of the spring on-sale the checkout also returned an error when Payrail was slow. What it did before returning the error was charge the card, and no test that looked at the status code would have noticed. Every failure test in Stagedoor asserts the state after the error, because the state is what the buyer, the reconciliation job and the on-call engineer will find in the morning.
Timeouts Are Tested by Being Hit
A timeout that has never fired in a test is a number in a config file. The Payrail-slow test makes the fake sleep 4 seconds against the client's 3-second read timeout and asserts that the handler returned in under 3.5, with a 504 and the payment-unresolved type, the order pending and the hold extended. The request deadline of Topic 37 gets the same treatment one level up: a handler wired to a fake that would take 12 seconds, against the 10-second budget, and an assertion that the response arrived at 10 with the deadline's error rather than at 12 with the fake's answer. These tests are slow by construction, 4 and 10 seconds each, so they carry the slow marker, run on merge rather than on every save, and there are exactly as many of them as there are timeouts in the configuration table: 7.
The temptation is to test the timeout with a smaller number, 300 milliseconds in the test config against a 400-millisecond sleep, and the temptation is right for most of the suite. The two slow tests above exist because the production number is the thing under test, and a suite where every timeout is overridden to 300 milliseconds has never once checked that the number in production is the number the handler honours.
The Partial Case
A dependency is rarely fully gone, and Topic 42 says the table's rows must hold for the partial case too. Two tests together are the breaker's threshold, written down. The first sets fake.fail_rate = 0.05, sends 200 checkouts, and asserts that every one of them succeeded, the breaker stayed closed, and the log shows about 10 retries: 5 percent is under the 50 percent threshold, the retry of Topic 38 absorbs it, and the buyer never knows. The second sets the rate to 0.6, sends 200, and asserts that the breaker opened within the first 40 calls, that the calls after it failed fast with 503 and no network attempt, and that the breaker gauge and the failure counter both moved. One test says the threshold is not too low; the other says it is not too high.
The partial test is the one that found Marek's first breaker, which counted a 402 decline as a failure. At a 5 percent decline rate, normal for a Saturday, the breaker opened after a busy minute and every checkout failed for 30 seconds while every card was fine. The test with fail_rate at 0.05 and 10 declines mixed in fails on the assertion that the breaker stayed closed, and that is the assertion that would have prevented the incident.
Recovery
Every failure test has a second half, and it is the half most often skipped: the condition is removed and the test asserts that the service comes back. The Payrail-down test clears the fake's script, advances the frozen clock past the 30-second cooldown, sends one checkout and asserts that the breaker went half-open, let the call through, saw it succeed and closed; the next checkout is a 201. The primary-paused test unpauses the container and asserts that the pool's acquire succeeds again and /readyz returns 200 within 5 seconds, without a restart. The replica test repoints the URL and asserts the replica pool of Topic 31 reconnected and the reads-on-primary counter stopped moving.
A service that degrades correctly and never recovers is the next incident, scheduled for the minute after the dependency returns. The half-open trial of Topic 40 is the kind of code that is written once, never runs in development, and is wrong in a way nobody sees until a breaker stays open for an hour after Payrail recovered. The recovery assertion is a fifth of the test's lines and the reason those lines have run.
- The dependency table with no tests — the first exercise of "Redis down" is the on-sale night, and the fallback limiter that was never wired is discovered by the primary falling over.
- Asserting only the status code — the 503 was right, and the card was charged before it was returned, which no assertion on the response can see.
- Failure injected by patching a function — the test passes against a patched call, and the real timeout path, through the real driver and the real socket, is a different piece of code that has never run.
- No partial-failure test — the breaker that counts declines as failures trips on a normal Saturday's 5 percent, and turns a healthy Payrail into a 30-second outage every busy minute.
- No recovery assertion — the half-open trial with a bug in it, and a breaker that stays open for an hour after Payrail came back, because the code that closes it has never once executed.
- Every timeout overridden to 300 milliseconds in the test config — the suite is fast and the production numbers have never been hit by any test at all.
- Write one failure test per row of the dependency table, asserting the client result, the rows and the internal signals together.
- Inject at the boundary only: the fake's controls, the container paused or stopped through Testcontainers, the config pointed at a closed port.
- Test each timeout by exceeding it with the production number, mark those tests
slow, and keep exactly one per timeout in the configuration table. - Test the breaker by crossing its threshold and by not crossing it, with declines mixed in, so both halves of the threshold are specified.
- Assert recovery in every failure test: the condition removed, the breaker closed, the pool reconnected, readiness 200, within a bounded time and without a restart.
pause and stop on the underlying container, the cleanest "it stopped answering"Toxiproxy latency, timeouts and reset connections injected at the network, between the service and a real dependencyChaos Mesh and Litmus the same injections at the cluster level, in Kubernetes Deep Dive's territorypytest markers, slow and commits, so the expensive tests run on mergeresilience4j and Polly breakers whose state is exposed for exactly these assertionsKnowledge Check
The Payrail-down test asserts a 503. What else must it assert for the table's row to be proven, and why?
- That the response arrived in under 100 milliseconds, proving the breaker failed fast
- That a log line was written at error level, because the alert text is generated from it
- That the client retried the 503 and succeeded, proving the Retry-After was honoured
- That the order is pending, the hold was extended, nothing was charged and the breaker is open
Which injection produces "the database stopped answering" most faithfully, and what does the alternative produce instead?
- Stopping the container; setting statement_timeout produces the same hang more slowly
- Pausing the container; a low statement_timeout gives a query-too-slow error, not a hang
- Patching the driver's execute to sleep; pausing the container would also stop the test runner
- Pointing the URL at a closed port; pausing produces a refused connection rather than a hang
Why does the partial case, Payrail failing 5 percent of calls, need its own test rather than being covered by the total-outage test?
- Because at 5 percent the breaker must open sooner, and only a partial test can time that precisely
- Because it specifies the other side of the threshold: the rate the breaker must not open on
- Because the partial case can only be produced against the sandbox, never against the fake
- Because partial failure only appears under load, so it belongs to the load test instead
What does the recovery half of a failure test protect against?
- A service that degrades correctly and then never comes back
- A test that leaves the container paused for the next test to find
- A fallback path that is heavier than the primary path it replaced
- A health check that restarts a healthy process during the outage
You got correct