Error Handling Discipline
Rules for failing fast, attaching context to errors, deciding catch versus propagate, and eliminating silently swallowed failures.
Use when: Use when writing code with failure modes — I/O, parsing, external calls — or when auditing a codebase for swallowed and contextless errors.
The skill file
Claude Code · SKILL.mdSKILL.md3508 chars
# Error Handling Discipline
Every error is eventually read by someone debugging at 2am. Handle it where you can act on it, enrich it where you can describe it, and never make it disappear.
## Fail fast
- Validate inputs at the boundary (request handler, CLI entry, message consumer) and reject immediately with a specific message. Don't let bad data travel five layers before exploding somewhere unrelated.
- Crash on startup for broken invariants: missing env vars, unreachable required services, bad config. A process that boots in a half-working state fails later, mysteriously, under load. Validate config at import/boot time with a schema, not lazily at first use.
- Assert impossible states instead of limping past them. `default: throw new Error(\`unhandled status: ${status}\`)` in a switch over an enum catches the new variant the day it's added.
## Catch vs propagate — the decision rule
Catch an error only if, at this level, you can do at least one of:
1. **Recover meaningfully** — retry with backoff, fall back to a cache or default, try an alternative path.
2. **Translate at a boundary** — convert a low-level error into the layer's own error type (DB unique-violation → `EmailAlreadyRegisteredError`) so callers depend on your contract, not your dependencies.
3. **Terminate the operation deliberately** — top-level handler that logs once, maps to a response/exit code, and emits metrics.
If none apply, do not catch. Let it propagate. Mid-stack `catch (e) { log(e); throw e }` produces the same error logged five times; log at the top-level handler only.
## Attach context, preserve the cause
- Re-throwing with more context must keep the original: use error chaining (`throw new X("...", { cause: err })`, `raise X(...) from err`, `fmt.Errorf("...: %w", err)`). A wrapped error without its cause amputates the stack trace.
- Context worth attaching: which operation, on which entity (IDs, not whole objects), with which relevant parameters. "Failed to sync invoice inv_8h2k to provider stripe: timeout after 30s" — versus "request failed".
- Never put secrets or full payloads in error messages; they end up in logs and error trackers.
## Banned patterns
- Empty catch blocks. If suppression is genuinely correct (e.g., best-effort cache invalidation), it needs a comment saying *why* and usually a metric counter — silence must be a decision, not a default.
- `catch (e) { return null }` — converts a described failure into an undescribed one that surfaces as a NullPointerException three calls later.
- Catching the broadest type (`Exception`, bare `except:`, `catch {}`) anywhere except the top-level boundary. Catch the specific errors you can actually handle.
- Unawaited promises / un-checked error returns. Every promise is awaited, returned, or explicitly given a rejection handler; every Go `err` is checked or assigned with a comment.
- Using exceptions for expected outcomes. "User not found" on a lookup form is a result (`null`, `Option`, result type), not a throw.
## Audit checklist for a diff
- [ ] Every catch block recovers, translates, or terminates — name which.
- [ ] Every re-throw chains the cause and adds operation + entity context.
- [ ] No error path returns a success-shaped value (null, empty list, zero) without the signature saying so.
- [ ] Failure paths have tests: the retry actually retries, the fallback actually falls back, the message contains the ID.
- [ ] Logs: errors logged exactly once, at the boundary, with stack trace and context.
Install
drop into your repoSave this to your project or home directory so Claude Code can load it.
path~/.claude/skills/error-handling-discipline/SKILL.md
Discussion
What people are saying
Forks
0 forks of this skillLoading forks…