modeling-errors
Use when deciding how a failure should be represented or handled — choosing between exceptions, result or either types, error codes, panics, and supervisors; writing a catch, rescue, or recover block; designing an error type or an error contract; deciding whether to wrap, log, rethrow, retry, or swallow. Also for silent failures, swallowed exceptions, a placeholder value that slips past the guard meant to stop it, undiagnosable production incidents, duplicated log noise, a retry whose reported error hides the failure that started it, and callers parsing error strings. Also use from the other side, when your own code must detect a state in a system you do not own and the only anchor on offer is a printed status word, a window title, or a generated class name, or when two different upstream failure causes render identically with no stable code to tell them apart, or when an eligibility, entitlement or block check against a remote service must decide what its own failed lookup means.
Overview
Classify the failure before choosing a mechanism. Almost all bad error handling is one mechanism applied uniformly to four kinds of failure that need four different treatments.
When to use
- Adding error handling to a new operation, or reviewing a
catchyou did not write. - Designing the error side of an interface: what callers can be told, and what they can do about it.
- Symptoms: a subsystem "silently does nothing", one failure produces four log lines, a retry loop that can never succeed, an incident where nothing recorded what actually failed, a retry whose reported error hides the failure that started it.
- Not for: the wire-level shape of the error contract at a public boundary (designing-apis), or the safety of the retry itself (designing-for-idempotency).
Four classes, four treatments
| Class | Examples | Mechanism | Caller's obligation |
|---|---|---|---|
| Expected, recoverable | not-found, validation rejection, version conflict, insufficient funds | Ordinary return value or a typed/checked failure — it is an outcome, not an exception to the rules | Handle it; this is normal control flow |
| Expected, fatal to this unit of work | missing config at startup, disk full, unparseable input stream | Propagate unchanged to the error boundary | Abandon the unit of work |
| Programmer error | broken invariant, precondition violated, unreachable branch reached, index out of range | Crash, panic, or abort as loudly as the environment permits | None — the caller is the bug |
| Infrastructure / transient | connection reset, lock timeout, throttle, peer restarting | Same as class 1, but carrying a retryable marker and a hint | Retry within a budget, then degrade |
Treating class 3 as class 1 is the single most common way a system becomes undiagnosable. A broad catch low in the stack turns a bug into a plausible-looking default value, and the wrong answer propagates for months. Class 3 must never be inside a retry loop and must never be caught by a generic handler. Where crashing is unacceptable (firmware, kernel, single-process device), fail the smallest schedulable unit and record the specific invariant that broke — do not substitute a default.
Class 4 is defined by one property only: retrying the identical input might succeed. If it cannot, it is class 1 or 2 no matter how infrastructural it looks. An authorization failure from a network call is not transient.
Which default gets substituted decides whether anyone finds out. The substitution is a defect wherever it happens, but the ones that survive for months share one property: the value substituted is the value a healthy system produces. An empty collection where the load failed, a zero where the read was never made, a false where the check could not run — each is indistinguishable on sight from good news, so nobody investigates, and the reading someone would act on is the wrong one. The same collapse with a value nobody could mistake for health gets found the same afternoon. So where a layer genuinely cannot propagate the failure and has to put something in the slot, the move is to widen the slot until absence is representable in it — an explicit unknown or not-yet-loaded state alongside the real values — rather than to reach for an in-band sentinel, which is the same collapse with an extra decoding step. The idiom that hides all of this is usually the one that discards the error on the way past, which is why it reads as concise rather than as dangerous.
The guard downstream repeats the collapse one step removed. Where several producers can fill one slot and only some of them can measure it, the check protecting a consumer from the placeholder tends to ask which producer ran rather than whether a value was observed. The two agree almost every time and disagree exactly where the guard is load-bearing: the measuring producer ran and came back empty, and its placeholder went through as a reading. The tell is a boolean named after a source or a branch, sitting beside a value that could have carried its own absence. The fix is to let the slot say not observed and delete the boolean, not to write a better one. A comment promising the consumer never sees a fabricated value is evidence the invariant was intended, never evidence that it held.
Choosing the mechanism
- Choose what the language makes ergonomic, not what you prefer. In an exception-based codebase, a lovingly hand-rolled result type does not remove exceptions — it just means third-party ones are now unhandled.
- The landscape: unwinding exceptions (Java, Python, C#, C++, Ruby); result/either values (Rust, Haskell, Swift typed throws, Go's error-return convention); error codes with out-params (C, syscalls, most embedded); conditions and restarts (Common Lisp — the handler decides at the raise point, the only mainstream mechanism that can resume); crash-and-supervise (Erlang, Elixir — class 3 discipline made structural).
- Three properties actually differentiate them: whether the type system can tell you an error is unhandled, whether the mechanism carries a cause chain and a stack, and whether the happy path pays a cost. Rank those for your context and the choice falls out; arguing result-versus-exception in the abstract does not.
- Whatever the mechanism: the set of failures an operation can produce is part of its signature, documented even when the language refuses to type it.
Errors as values a caller can act on
Five things, every time:
- What failed — a stable machine-readable code. Not prose. Prose gets reworded and somebody downstream is matching on it.
- What was attempted — the operation plus identifying inputs, secrets and personal data redacted.
- Whether retry can help — a boolean, plus a retry-after duration when known. The callee knows this; the caller is guessing.
- A correlation identifier — request, trace, or job id, so a user's report maps to your log line.
- The cause chain — preserved as structure, never flattened into a string.
A good message names what was being done, what happened, and what changes the outcome: connect to config store at <addr>: connection refused (retryable, retry after 2s). Never "an error occurred"; never a message that only makes sense to the person who wrote the raise site.
When you are the downstream and no stable code was published
Rule 1 above is written for whoever publishes the contract. Sooner or later you are on the other end of one that was never written: your code has to detect a state in a system you do not own, and that system offers no machine interface. The most obvious anchor is then the surface it renders for people — a status word in a command's printed output, a window's title, a class name in someone else's generated markup — and matching on it is wrong in a way your own machine is structurally unable to show you. Printed status words are localized, so on every install not in your language your check reads a perfectly healthy target as stopped. A title substring matches your own window as readily as the one you meant, which is merely embarrassing until the same pattern is chained to something destructive. Generated class names are hashed per build and change on a release nobody told you about.
So choose the anchor by asking which layer treats the value as identity or state rather than as presentation. An exit status carries no language. A process id, an owning window handle, a socket that answers, an attribute set beneath the layer doing the hashing — each is something the target system uses to keep track of itself, and it moves when the target's behaviour moves rather than when its rendering does. Two riders. Very often the rendered string answers a worse question than the one you actually hold: is this reachable is answerable directly, while does the supervisor's bookkeeping currently spell a particular word is a proxy for it that a translation can break. And where only a fragile anchor exists, a fallback ladder needs each rung proven with the rungs beneath it disabled — otherwise a sturdy fallback silently rescues the flimsy rung above it, the test passes for the wrong reason, and the flimsy rung is the one that ships to a machine where the fallback is unavailable.
Two causes that render identically must not share one verdict
The section above is about detecting state with no stable signal. The same gap shows up one level over, in classifying a failure rather than a state: an upstream API or CLI that gives no stable, structured code for either case can make "you have been throttled" and "the service is actually broken" arrive as the identical shape — an error, then silence. Both are class 4 by the definition above (retrying might succeed), but they are not the same failure, and folding them into one generic "degraded" verdict is the specific hazard, because whatever consumes that verdict downstream — a health check, a dashboard, an escalation — can no longer tell which one happened once the merge has occurred. Trust an explicit signal when the upstream provides one (a Retry-After-equivalent); fall back to matching the error's own text only when nothing structured exists; and once a quota condition is confirmed, treat it as its own outcome that pauses the surrounding batch or session, rather than retrying call-by-call or reporting it through the same channel a real outage reports through.
A gate's lookup has three answers, not two
An eligibility or block check against a remote service — is this account in good standing, is this installation allowed, is this user blocked — tends to be written as a boolean, and then the lookup's failure has to land in one of the boolean's two values. Either landing is a defect, and they are opposite ones. Count failure as refusal, and a short outage of the backing service locks out every confirmed user — so does simply being offline, in an app whose point is working offline — while the authors, testing on a good connection, never see it. Count failure as approval, and the one party the gate exists to stop only has to make the lookup fail. They need opposite handling because they are different kinds of fact: a refusal is information the service gave, and silence is the absence of any.
So model three outcomes, allowed, refused and unknown, and give each its own rule:
- A definitive answer acts at once and clears what it contradicts. That includes the service's refusal status codes, not only a well-formed "no" in a body. A refusal that was actually delivered disconnects now and discards any stored confirmation; nothing about outages softens it, and nothing retries it into a different verdict.
- Silence is neutral, and what carries it is a confirmation, not an identity. While the service cannot be reached, the gate keeps its last state for a named bound, earned by a positive confirmation, stamped by the server rather than the device, and younger than that bound. The cached identity, name or token never carries it: those say who the user was, not that anyone has checked them lately, and a gate that carries on a stored name never closes. Past the bound silence stops being neutral and the gate fails closed, so a block cannot be outrun by staying offline.
- The bound is measured on a clock the test can set, and a rewound clock fails closed. Inject the clock so both sides of the bound get tested, and read a confirmation stamped later than now — the device clock moved backwards — as expired, not as fresh forever.
- A re-check leaves the confirmed state alone while it runs. If checking again moves the state to "checking" and the screen behind the gate is built from that state, every resume or periodic re-check tears the screen down for a user nothing has changed for. Re-check silently and move state only on an answer.
The test double has to be able to say all three. A fake that returns true or false cannot express silence, and a suite written against it passes with the defect inside; give it timeout, server-error and refusal modes and assert each separately. caching-strategy's rule against caching a timeout is the same distinction applied to storage, and feature-flagging's default of deny for an entitlement is what a principal nothing is known about receives, not the verdict for one the service confirmed recently and cannot reach now.
Wrap, swallow, rethrow, boundary
| Action | Correct when | How it goes wrong |
|---|---|---|
| Wrap with context | Crossing a layer where the inner error lacks nouns the caller knows | Five layers of failed to X: failed to Y: failed to Z: EOF. Wrapping must add nouns (which file, which record, which attempt), never verbs |
| Swallow | The failure is genuinely not an outcome — an optional cache read, a best-effort metric | No counter, so a 30%-failing subsystem goes unnoticed for a year. Swallowing requires a comment naming why and a metric |
| Rethrow unchanged | You had nothing to add | Underused; people wrap out of habit |
| Translate to your own type | At a boundary you own, so vendor error types stay out of your callers | Translating everything to one generic type, destroying the classes above |
| Retry | Class 4 only, at a layer that owns a deadline and a budget | Retries nested at three layers multiply: 3×3×3 attempts and a 27× load amplification during an outage |
The error boundary is one per unit of work — request, message, job, frame, transaction. It catches whatever remains, logs once with the full chain and the correlation id, converts to the caller-facing contract, and decides the unit's disposition (fail, retry, dead-letter). A service typically has 2–5, one per kind of unit. Log at the boundary, not at the raise site, and never at both: log-and-rethrow at each layer is why "how many errors happened" is unanswerable in most systems.
A retry that keeps only its last error can report the wrong failure. Keeping the last attempt's error is what a retry loop does unless told otherwise, and it is harmless only while every attempt starts from the state the first one did. Once an attempt can leave something behind — a process still starting, a lock still held, a half-written file, a connection still open — the next attempt meets that leftover and fails at once, for a reason that is real, specific, and caused by the first attempt. The report now names the consequence; the cause, which only the first attempt saw, is recorded nowhere. When whatever sits above the retry runs the whole unit again, every later pass meets the same leftover and reports the same consequence, so the log fills with one confident explanation of the wrong failure. The boundary that owns the retry therefore keeps every attempt's reason instead of overwriting it: each distinct reason in order, the first one leading, a repeat listed once with its count. One test pins it — an attempt that fails one way, a second that fails another way because of the first, and a report that still names the first — and a retry that later succeeds reports the success, not the history.
Common mistakes
| Symptom | Real cause |
|---|---|
| One failure appears four times in the logs at four layers | Log-and-rethrow instead of logging once at the boundary |
| An incident with no record of which call failed | Broad catch low in the stack substituting a default |
| Retry loop burns quota and never succeeds | Non-retryable class retried; no retryable marker in the error |
| "It just returns empty" | Not-found modeled as an empty collection; caller cannot distinguish |
| Callers parse error message text | No stable code in the contract |
| Bug reproduces only in production and leaves no trace | Programmer error caught by a generic handler |
| Wrap chain reads "failed to process: failed to handle: failed to run" | Wrapping with verbs instead of nouns |
| One bad message kills the whole consumer | Error boundary at process granularity instead of per-message |
| Load spike during a partial outage | Retries nested at multiple layers, multiplying attempts |
| The log repeats one specific, plausible failure while the failure that started it appears nowhere | A retry kept only its last attempt's reason, and the later attempts failed because of what the first one left behind |
| A detector works for the author and reports the wrong state on every other machine | It matched a rendered string — a localized status word, a title, a generated class name — instead of something the target uses as identity |
| A fallback ladder's first rung has never been seen to succeed on its own | Every rung was tested with the ones below it live, so the sturdy fallback silently rescued the fragile probe |
| A health/quality verdict looked broken during what was actually a rate-limit window | Quota exhaustion and a genuine outage rendered identically upstream and were folded into one "degraded" signal |
| Every confirmed user was disconnected during a short outage of the service that backs an eligibility check | The lookup's failure was counted as a refusal, so silence and "no" shared one verdict |
Red flags
- "Catch everything here so it doesn't crash."
- "Log it and continue."
- "Return null on failure and let the caller figure it out."
- A default chosen at the point of failure that is also what success looks like.
- "That can never happen" — written next to the code that handles it happening.
- A state check that matches a word a human was meant to read.
- An unanchored substring match that would also match your own program.
- Two upstream failure causes that render identically, folded into one verdict because nothing structured tells them apart.
- An access check whose lookup returns a boolean, so a failed lookup has to be one of true or false.
- "Add a retry" as the first response to a flake, before classifying it.
- A catch block whose body is empty, or contains only a log statement.
- An error type with one variant and a string.