Lesson 18 / 19
Failure Modes
What failure modes correspond to in application code: the same endpoint breaking eight separate ways, producing five different error identities at the call site, the pattern stack seeing five of them and missing three entirely, and the same zero value reaching the call site under six modes.
Contents
Six patterns were built in this topic. Each was written against a way of breaking, each has a place in the code, and each had its uncovered call sites counted. What the patterns do is clear; which failure they correspond to was never spelled out one by one.
The failure-mode vocabulary — how many ways a dependency can break, and each one’s effect on the system — was built in Resilience and Reliability. The question here is different: what does each mode appear as in application code. Does the call site get an error object, a timeout, an empty body, or a valid response showing no symptom at all. This is the topic’s closing lesson, and it counts the patterns’ total coverage.
Producing Eight Modes in Code
RS15 — eight modes. A single billing endpoint breaks eight separate ways: connection refusal, a delayed response, a server error, an empty body, a body cut off partway through, a field with the right shape but wrong type, a valid and correctly typed but wrong value, and slowness just under the timeout. The last three all return HTTP 200 and valid JSON. The actual balance is 12.5, and the late fee adds five to it.
RS16 — the stack’s settings. Timeout 120 ms, retry count 2, breaker threshold 3 consecutive failures, 5 calls per mode. These values were measured in earlier lessons; here they are held fixed.
// mode/mode.mjs — a dependency's eight failure modes and the patterns stacked on top. import http from "node:http"; export const ACTUAL = 12.5, TIMEOUT = 120, RETRIES = 2, THRESHOLD = 3, CALLS = 5; const send = (res, g) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(g)); }; // Every mode breaks the same endpoint a different way. The last two return HTTP 200 and valid JSON. export const MODE = { "connection-refused": null, // no listener "timeout": (res) => setTimeout(() => send(res, { balance: ACTUAL }), 400), "server-error": (res) => { res.writeHead(500, { "content-type": "application/json" }); res.end("{}"); }, "empty-body": (res) => { res.writeHead(200, { "content-type": "application/json" }); res.end(""); }, "truncated-body": (res) => { res.writeHead(200, { "content-type": "application/json", "content-length": "20" }); res.write('{"balance":'); res.socket.destroy(); }, "wrong-type": (res) => send(res, { balance: String(ACTUAL.toFixed(2)) }), "silent-wrong": (res) => send(res, { balance: 0 }), "slow-under-limit": (res) => setTimeout(() => send(res, { balance: ACTUAL }), 90), }; export function breaker() { let consecutive = 0; return { isOpen: () => consecutive >= THRESHOLD, success() { consecutive = 0; }, failure() { consecutive += 1; } }; } // Timeout + retry + circuit breaker in a single wrapper. Which one triggers is written to the trail. export async function guarded(port, k, trail) { if (k.isOpen()) { trail.add("breaker"); throw new Error("circuit open"); } let last; for (let d = 1; d <= RETRIES; d += 1) { try { const y = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(TIMEOUT) }); if (y.ok === false) { const e = new Error(`HTTP ${y.status}`); e.name = `HTTP ${y.status}`; throw e; } const g = await y.json(); k.success(); return g; } catch (e) { last = e; trail.add(e.name === "TimeoutError" ? "expired" : "error"); if (d < RETRIES) trail.add("retry"); } } k.failure(); throw last; } export const symptom = (e) => (e.cause?.code ? `${e.name}/${e.cause.code}` : e.name); // Call site: the degradation fallback lives here. The fallback value is the same as what silent-wrong returns. export async function callSite(port, k, trail) { try { return { value: await guarded(port, k, trail), fallback: 0, label: "HTTP 200" }; } catch (e) { trail.add("fallback"); return { value: { balance: 0 }, fallback: 1, label: symptom(e) }; } }
The Pattern Stack and the Measurement
The measurement produces each mode in turn and makes five calls. Four things get recorded: the error identity that shows at the call site, the patterns that trigger, how many calls pass without falling back, and the balance value reaching the call site along with the late fee calculated from it.
// mode/measurement.mjs — produces each mode one at a time, writes what shows at the call site and which pattern triggers. import http from "node:http"; import { MODE, ACTUAL, TIMEOUT, RETRIES, THRESHOLD, CALLS, breaker, callSite } from "./mode.mjs"; let current = "silent-wrong"; const s = http.createServer((req, res) => MODE[current](res)); await new Promise((r) => s.listen(0, "127.0.0.1", r)); const PORT = s.address().port; const dead = http.createServer(() => {}); // a port whose listener has been closed await new Promise((r) => dead.listen(0, "127.0.0.1", r)); const DEAD_PORT = dead.address().port; await new Promise((r) => dead.close(r)); console.log(`run-independent: timeout ${TIMEOUT} ms, retries ${RETRIES}, breaker threshold ${THRESHOLD}, ${CALLS} calls per mode`); console.log(`actual balance ${ACTUAL}; the late-fee calculation should be balance + 5 = ${ACTUAL + 5}\n`); console.log(`${"mode".padEnd(20)}${"seen at call site".padEnd(27)}${"pattern triggered".padEnd(33)}${"passed".padStart(6)}${"balance".padStart(9)}${"balance+5".padStart(11)}`); const rows = []; for (const mode of Object.keys(MODE)) { current = mode; const k = breaker(), trail = new Set(); let passed = 0, first = null; for (let c = 0; c < CALLS; c += 1) { const y = await callSite(mode === "connection-refused" ? DEAD_PORT : PORT, k, trail); if (y.fallback === 0) passed += 1; first ??= y; // the symptom is read from the first call } const seen = trail.size > 0, correct = first.value.balance === ACTUAL; rows.push({ mode, seen, correct, trail }); console.log(`${mode.padEnd(20)}${first.label.padEnd(27)}${([...trail].join("+") || "none").padEnd(33)}` + `${`${passed}/${CALLS}`.padStart(6)}${String(first.value.balance).padStart(9)}${String(first.value.balance + 5).padStart(11)}`); } s.close(); const unnoticed = rows.filter((x) => x.seen === false); console.log(`\n${rows.length - unnoticed.length} of ${rows.length} modes triggered at least one pattern, ` + `${unnoticed.length} triggered none: ${unnoticed.map((x) => x.mode).join(", ")}`); const wrong = rows.filter((x) => x.correct === false); console.log(`the actual value never reached the call site in ${wrong.length}/${rows.length} modes: a pattern reported it in ${wrong.filter((x) => x.seen).length} modes, ` + `${wrong.filter((x) => !x.seen).length} modes carried no report at all`); console.log("\ncoverage per pattern: " + ["expired", "error", "retry", "breaker", "fallback"] .map((kl) => `${kl} ${rows.filter((x) => x.trail.has(kl)).length}/${rows.length}`).join(", ")); console.log(`run-independent upper bound: a failed call waits at most ${RETRIES * TIMEOUT} ms, 0 ms once the circuit is open`);
run-independent: timeout 120 ms, retries 2, breaker threshold 3, 5 calls per mode actual balance 12.5; the late-fee calculation should be balance + 5 = 17.5 mode seen at call site pattern triggered passed balance balance+5 connection-refused TypeError/ECONNREFUSED error+retry+fallback+breaker 0/5 0 5 timeout TimeoutError expired+retry+fallback+breaker 0/5 0 5 server-error HTTP 500 error+retry+fallback+breaker 0/5 0 5 empty-body SyntaxError error+retry+fallback+breaker 0/5 0 5 truncated-body TypeError/UND_ERR_SOCKET error+retry+fallback+breaker 0/5 0 5 wrong-type HTTP 200 none 5/5 12.50 12.505 silent-wrong HTTP 200 none 5/5 0 5 slow-under-limit HTTP 200 none 5/5 12.5 17.5 5 of 8 modes triggered at least one pattern, 3 triggered none: wrong-type, silent-wrong, slow-under-limit the actual value never reached the call site in 7/8 modes: a pattern reported it in 5 modes, 2 modes carried no report at all coverage per pattern: expired 1/8, error 4/8, retry 5/8, breaker 5/8, fallback 5/8 run-independent upper bound: a failed call waits at most 240 ms, 0 ms once the circuit is open
Five Identities at the Call Site, Two Paths
The second column gives the eight modes’ identities on the application side, and they do not
resemble each other. Connection refusal produces a TypeError, its real cause only showing up in
cause.code. Timeout is a separate class, TimeoutError. A server error throws nothing at all —
a resolved response comes back, and checking the status code is left to the caller; skip that
check, and a 500 counts as success. An empty body is a SyntaxError, a parsing error: nothing
is wrong at the network layer, the body itself is invalid. A truncated body is again a TypeError,
with a different cause.code.
Five separate identities, read from five separate places: the error class, the error cause, the status code, the parse result. Recognizing a failure mode in code requires looking at all four of these sources at once.
The behavior in the last columns, by contrast, is uniform. The same four patterns trigger on all five of those modes, and all five calls fall back. The pattern stack does not distinguish between modes; it reduces every one to a single category: the call failed. This is not a flaw in the patterns, it is the limit of their scope — but the difference between connection refusal, which says the service is down, and an empty body, which says it produced a broken response, disappears once it passes through the stack. Only a log entry recording that error identity can carry the difference forward.
The coverage-per-pattern row puts a number on this. Timeout triggers on only one of eight modes: the delayed response. The remaining four failing modes already throw an error on their own and never need it. Retry, breaker, and the degradation fallback each trigger on five modes. The most expensive pattern — the breaker — is also the most commonly triggered; timeout, narrowest in coverage, is the only one whose absence leaves a call hanging indefinitely. Coverage width and importance are not the same thing.
The Modes No Pattern Meets
The bottom three rows are this lesson’s real result. For wrong type, silent wrong value, and
below-limit slowness, the pattern triggered column reads none, and all five calls pass. The
pattern stack does not see these modes, because every pattern triggers on a symptom in the
transport layer: the connection couldn’t be made, time ran out, the status code was wrong, the
body couldn’t be parsed. In these three modes the transport layer works flawlessly: connection
made, response on time, status 200, body valid JSON.
The results, though, are not flawless. When the wrong-type field arrives, the late-fee calculation
produces 12.505: text arrives where a number was expected, and addition turns into
concatenation. What comes out is not an error but a wrong fee, written to the member’s
account. Under the silent wrong value, the fee comes out 5 instead of the genuine 17.5. Under
below-limit slowness the value is correct, and the only cost is time spent; to the stack, this
mode is indistinguishable from a healthy call.
The reason the patterns do not meet these modes is not a design flaw, it is a missing input. A pattern needs a symptom to trigger on; in these three modes, the symptom is not in the transport layer, it is in the data itself. What would see it is not a resilience pattern but a validation that checks the response’s schema and value — and that validation was never written in this topic.
Zero’s Three Meanings
One last measure sits in the column of the value reaching the call site. In six of the eight
modes, the balance value is zero. In five of those, zero is a degradation fallback: a
pattern triggered, the failure was seen, and the value put in its place was zero. In the sixth,
zero is the value the service actually returned, and behind it there is no pattern, no record,
no symptom at all.
The previous lesson measured that a fallback value is indistinguishable from a genuine one; the table here goes one level deeper. The same number tells three stories: genuine data, the fallback a pattern put in place, and the wrong value a dependency silently produced. Code reading the stack’s output cannot tell these apart. The only way is to write down, next to the value, where it came from: a fallback marker provides that in five modes, and in the sixth there is nothing to provide it.
Up to this point, the topic counted where the patterns sit: wrapped and uncovered call sites, paths left outside the limit, the behavior a wrong key produces. This last measurement gives the coverage as a whole. Of eight modes, five are seen and three are not, and two of the three unseen carry a wrong value to the call site. Patterns do not make a failure visible; they only make visible the symptom that triggers them. A failure with no symptom is just as invisible with patterns as without.
Summary
- The failure-mode vocabulary was built in the Resilience and Reliability course; the measure here is which identity each mode shows in application code.
- Eight modes produce five separate identities at the call site, read from four places: error
class (
TimeoutError), error cause (ECONNREFUSED,UND_ERR_SOCKET), status code (HTTP 500), and parse result (SyntaxError). - The pattern stack does not distinguish these five identities; the same four patterns trigger on all five and every call falls back. The difference between modes disappears once it passes through the stack.
- Coverage per pattern is not equal: timeout triggers on 1 of 8 modes, retry, breaker, and the degradation fallback on 5. Coverage width is not the same thing as importance.
- 3 modes trigger no pattern — wrong type, silent wrong value, and below-limit slowness — because
in all three the transport layer works flawlessly. Two carry a wrong value to the call site: the
late fee comes out
12.505and5instead of17.5. - In 6 of 8 modes, zero reaches the call site; in 5, it is a fallback value carrying a marker, and in 1, it is the dependency’s silent wrong value, carrying no marker at all.
Next Step
This topic built the patterns, counted where they sit in the code, and measured what falls outside their coverage. Every number measured describes one system: two of fourteen call sites unwrapped, two of eight path-access pairs outside every limit, three of eight failure modes seen by no pattern. None of these gaps was learned when written; each was learned when a failure touched it.
Learning itself, though, is not yet a mechanism. How a timeline gets built once an incident happens, which signals from the first three topics fill that timeline, what the outcome tells whom, and how the lesson learned makes its way back into the code — none of that has been designed. The next lesson takes up this mechanism and asks: which of this topic’s numbers can the text written after an incident actually change.
To keep your progress and take notes, Log in
My notes
Log in to take notes.