57e03af2b9
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
132 lines
8.5 KiB
Markdown
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.)*
|