The reporting model¶
This page explains the shape of the package: what belongs to the error, what belongs to the report, and why the split is drawn where it is.
Two concerns, deliberately separate¶
Creating an error and reporting one are different jobs, done in different places, by different code:
| Creation | Reporting | |
|---|---|---|
| Who | the code that detected the problem, anywhere in your tree | one place, near the top |
| With what | cockroachdb/errors + the helpers here |
ErrorHandler |
| Produces | an error carrying context, a hint, a stack, maybe an exit code | log output, and possibly process exit |
Everything below follows from keeping those apart. Deep code enriches an error and returns it; it does not decide the program's fate. One place near the top decides.
Bubble up; don't exit deep¶
Report errors at the top, not where they happen. A Fatal (or a bare os.Exit)
buried in business logic terminates the process immediately — deferred functions do
not run, so buffered writes are lost, temp files survive, and connections close
uncleanly.
// ✅ return the error; let the caller decide
func doWork(ctx context.Context) error {
if err := step(); err != nil {
return errors.Wrap(err, "step failed")
}
return nil
}
// ❌ decides the whole program's fate from deep inside it
func doWork(ctx context.Context) {
handler.Fatal(step())
}
Library-ish code should create and wrap errors and let its caller judge how fatal they are. The same error might be fatal to one command and a warning to another — only the caller knows.
Levels¶
| Level | Behaviour |
|---|---|
LevelFatal |
log at error, then exit with the error's exit code |
LevelFatalQuiet |
exit identically, but log at debug — for expected terminations like SIGINT |
LevelError |
log at error; keep running |
LevelWarn |
log at warn; keep running |
Fatal / Error / Warn are convenience wrappers over Check(err, prefix, level).
An unrecognised level deliberately falls back to logging at error rather than
silently swallowing the error — a reporting bug must never make a failure invisible.
Check(nil, …) is a no-op, so handler.Check(doThing(), "", LevelError) is safe
without a preceding nil test.
What gets rendered, and when¶
A report is assembled from the error itself:
- The message — always.
- Hints — always, when present. A hint is the actionable half of an error and is useless if it only appears in debug mode.
- Stack traces and details — only when the logger has debug enabled. This is
decided by asking the logger (
Enabled(ctx, slog.LevelDebug)), not by reading a level off a config, so whatever your slog handler considers debug is what counts. - A support message — when a
HelpConfigis supplied and returns a non-empty string.
The prefix argument is not prepended to the message. It is attached as a structured
prefix attribute, so it appears as prefix=cache-update alongside the message rather
than being baked into the text — greppable, and it doesn't corrupt the message for
anything matching on it.
The exit code belongs to the error¶
A failure knows its own severity at the point it is detected, but the process exits somewhere else entirely. Rather than thread an exit code through every return, attach it to the error and let it travel:
The attachment is transparent — errors.Is and errors.As still see through it, and
it survives further wrapping — so nothing downstream has to know it's there. At the
top, the fatal path reads it back. ExitCode(nil) is 0, and an error with nothing
attached is 1.
The payoff is architectural: one exit path. No os.Exit scattered through the
tree, so there's exactly one place where the process can die and exactly one place to
change if that behaviour needs to.
Sentinels that mean something¶
Two error values trigger behaviour rather than just being reported:
ErrNotImplemented— reported as a warning, not an error: a stub is not a failure.NewErrNotImplemented(issueURL)attaches a tracking link, which the handler surfaces so a user can follow progress rather than just being told "no".ErrRunSubCommand— a parent command invoked with no subcommand. The handler prints usage through theSetUsageseam and warns. Not finding a subcommand is a usage problem, so the answer is to show the usage.
Assertion failures are different¶
NewAssertionFailure marks an internal invariant breach — "this should be
impossible" — rather than a user-facing problem. Those are reported as internal errors
and carry their detail into debug output. The distinction matters because the two
audiences differ: a user can act on "config file not found"; nobody outside your team
can act on a violated invariant.
Note that an assertion failure still falls through to normal reporting afterwards, rather than short-circuiting — the failure is surfaced and logged through the usual path.
Why an interface¶
ErrorHandler is an interface so the reporting boundary can be substituted. Tests use
the published mock to assert that something was
reported without parsing log output; an application can swap in its own implementation
entirely. The concrete StandardErrorHandler remains exported for callers that want
to construct it directly.