Files
handbooks/failure-handling.md
T
mikkeli 57e03af2b9 docs: add frontmatter convention (title, collections) to handbooks
Each handbook now opens with a flat YAML frontmatter block so the
two ingest-time values that actually describe the document -
title and collections - live with it instead of being retyped by
hand at admission time. That drift was real, not hypothetical:
the ingest recipe documented `--collection dev-practice` while the
actual admission used `handbooks`.

`version` deliberately stays out of frontmatter - a file can't
contain the hash of the commit that writes it - and remains a
value the ingest recipe computes fresh, now scoped per-file
(git log -1 -- <file>) rather than the whole repo's HEAD.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011EBCcjJvEvhpToahqdsLLA
2026-08-22 17:05:23 +09:00

132 lines
8.5 KiB
Markdown

---
title: Failure handling
collections: [handbooks, dev-practice]
---
# Failure handling
> Composed 2026-08-22. General practice, not tied to any one project or language.
## Scope
General discipline for how code should behave when it encounters conditions it can't cleanly
complete against — malformed input, an unreachable dependency, an assumption that turns out false.
Two sections: what a function's contract should say about the input space before it's written, and
what happens once something has actually gone wrong.
Excluded on purpose: language-specific exception syntax, logging infrastructure choices,
testing-framework mechanics, and environment/verification parity ("tested" vs. "tested where it
actually runs") — that last one is close enough to earn a future handbook of its own rather than a
section here.
## Input contract discipline
**Degenerate input is the default case to design for, not an edge case bolted on after.** Empty
collections, duplicate entries, out-of-range values, malformed or wrong-typed data, oversized
payloads, and concurrently-modified reads are not rare — they're what actually arrives once code
leaves a demo. Design for that space first, and treat "obviously fine" input as the special case, not
the other way around.
**Validate, sanitize, and reject are three different responses — not one blurred behavior.**
- **Validate** — check conformance against the contract, making no change to the input. Proceed only
if it holds.
- **Sanitize** — transform nonconforming input into conforming input. The transformed value is what
proceeds, treated exactly as if the caller had sent it that way to begin with.
- **Reject** — refuse to proceed, surfacing why (see Failure-propagation discipline for what
"surfacing why" should actually look like).
**Sanitize only what's meaning-preserving; reject anything that requires guessing intent.**
Sanitizing has a real cost: the caller's actual input is never seen again, only its cleaned-up
substitute. That's fine when the correction is unambiguous — trimming incidental whitespace,
normalizing Unicode form, case-folding under an already-documented case-insensitive contract. It's
not fine when the correction requires guessing what the caller meant — clamping an out-of-range
number to its nearest valid bound, silently truncating an oversized value, defaulting a missing
required field. The test: *would the caller be surprised to learn what actually happened to their
input?* If yes, reject instead of sanitizing — finding out via an error immediately is strictly
better than finding out weeks later that a value was quietly substituted for the one actually sent.
Silent sanitization of an out-of-range value and a guard that returns success without checking are
the same failure shape: something couldn't proceed correctly, and produced a plausible-looking result
instead of saying so.
**Validate at trust boundaries; trust your own internals.** Check input where it crosses from outside
your control into it — user input, an external API response, a file read from disk, a message from
another service. Code on the inside of that boundary, already known to satisfy its own contract (by
type, by test, by the caller being code under the same control), doesn't need to re-validate what its
caller already guaranteed — that's redundant defense that doubles maintenance cost without doubling
safety. The boundary is where trust actually changes; that's the only place validation earns what it
costs.
**Before writing the implementation, write the contract's answer for each category.** A function's
contract should state, explicitly, what happens for: empty input, a duplicate where uniqueness was
assumed, a value outside the expected range, a type mismatch, an oversized input, and a stale or
concurrently-modified read. Leave any of those unanswered, and the implementation will invent an
answer under latitude — usually the same way, by quietly doing something plausible instead of
refusing. Writing the answer down first is what closes that gap; discovering it during implementation,
or worse in production, is what leaves it open.
## Failure-propagation discipline
**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.
**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.)*