Files
handbooks/failure-handling.md
T
mikkeli 4ed06c7e4b docs: compose the failure-handling handbook
Two sections, forged the same way as git-operations.md: input
contract discipline (degenerate input as the default case,
validate/sanitize/reject as distinct responses, the
meaning-preserving line for when sanitizing is safe) and
failure-propagation discipline (never substitute a plausible
artifact for a failed operation, retry/abort/surface, one
signaling convention per boundary, the anti-pattern catalogue).

Environment/verification parity was scoped out deliberately —
different axis (confidence-establishment, not code behavior),
earns its own handbook later rather than a section here.

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

8.4 KiB

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.)