diff --git a/failure-handling.md b/failure-handling.md index 0078d00..4a1fbf6 100644 --- a/failure-handling.md +++ b/failure-handling.md @@ -1,7 +1,6 @@ # Failure handling -> Composed 2026-08-22. General practice, not tied to any one project or language. Input contract -> discipline is composed; failure-propagation discipline is still outline. +> Composed 2026-08-22. General practice, not tied to any one project or language. ## Scope @@ -63,6 +62,65 @@ or worse in production, is what leaves it open. ## Failure-propagation discipline -*(Not yet composed.)* +**Never substitute a plausible-looking artifact for a failed operation.** When something can't +complete its contract, the correct output is a signal that it didn't — not a value that resembles +success closely enough to pass unnoticed. A blank result where real output should be, a guard that +reports "fine" because it couldn't read what it was checking, a stale cached value returned as if it +were fresh: each of these is a failure wearing success's clothes, and the cost is always paid later, +by whoever trusted the output. -*(Handbook in progress — sections above stand as citable units; the rest is outline.)* +**A check that can't verify must refuse — and "couldn't verify" must be a different state than +"verified, and it's fine."** Collapsing both into one boolean is the mechanism behind the failure +above: a function that returns `true` for both "the input passed validation" and "validation itself +couldn't run" cannot be trusted by anything downstream, because the caller has no way to tell which +one happened. Whatever the check returns, its failure-to-run and its pass result need to be +distinguishable — a third state, a different return type, an exception path, anything that doesn't +require the caller to assume good faith on the checker's behalf. + +**Retry, abort, and surface are three different responses to a failure, and picking the wrong one is +a fail-loudly violation on its own.** + +- **Retry** only for failures that are plausibly transient and where repeating the operation doesn't + change its meaning (a network read, not a payment). Bound it — a fixed number of attempts or a + timeout, never unconditional — and don't let a bounded retry become an unbounded one by accident (a + retry nested inside another retry, a caller that itself retries a function that already retries + internally). +- **Abort** when the failure indicates a broken invariant or a state that's unsafe to continue from — + retrying or asking would just compound whatever's already wrong. +- **Surface** — to a caller, a human, or an error return — when the system doesn't have enough + information to pick correctly on its own. This is usually the right default when nothing above + clearly applies: asking is cheaper than guessing wrong. + +**A retry that eventually succeeds is still a failure that happened, and needs to be recorded as one, +not discarded because the second attempt worked.** The caller downstream can get the successful +result — that's the point of retrying — but "the first attempt failed" is real information: it's how +a dependency's slow slide toward actually breaking becomes visible before it breaks outright, instead +of only being visible in the moment it finally does. A retry policy that only speaks up once retries +are exhausted throws that signal away for free. + +**Pick one failure-signaling convention per boundary, and don't mix it with another.** Exceptions, a +`Result`-shaped return, a sentinel value, an error code — any of these work, but only if every caller +at that boundary can rely on which one is in play. Mixing them within the same boundary is its own +failure mode: a caller checking a sentinel value for a function that actually raises, or catching an +exception type a function never throws, proceeds past the real failure exactly as if it hadn't +checked at all — the check existed, and did nothing. + +**Anti-patterns, all variations on the same shape:** + +- A swallowed exception (`except: pass`, an empty `catch` block) — the failure existed for one + instruction and then didn't. +- `continue` on a caught exception inside a loop — one bad item disappears with no record it was ever + there, and the loop reports success for everything it actually attempted. +- A fallback default that isn't marked as one — returning `0`, `null`, an empty collection, or a + stale cached value in place of a failed real result, with nothing distinguishing it from a genuine + `0`, `null`, empty collection, or fresh value. +- A comment explaining away a shortcut instead of the code being fixed — `# TODO: handle this + properly` sitting next to the exact silent failure it describes, left in place. +- An unbounded retry — functionally identical to abort, except it costs resources first and never + actually informs anyone that it gave up. + +The same discipline shows up in test coverage: a suite that only exercises the happy path hasn't +tested the code, it's tested the narrative about the code. + +*(Handbook composed 2026-08-22. Both sections above stand as citable units — a chunk boundary, not +just a heading.)*