Skip to content
← Back to Blog

Every Go program has if err != nil. Then what?

·5 min read

if err != nil is the most-written line in Go. We joke about it, we meme about it, we type it thousands of times per codebase. And the line is fine. The problem is the line after it.

Then what? Should the caller retry? What exit code should the CLI return? What do you tell the user? In most codebases, the honest answers are:

if strings.Contains(err.Error(), "timeout") {
    // retry? probably? the string said so
}
fmt.Println(err) // "pgconn: conn busy" — thanks, very actionable
os.Exit(1)       // file not found: 1. disk on fire: also 1

String-matching a message that was never a contract. One exit code for every failure mode. Internal jargon handed to a user as if it were an explanation. I have written all three. Statistically, so have you.

So I built go-error-family. One idea: an error should know whose fault it is — and everything else should follow from that. The retry decision. The exit code. The HTTP status. The tone of the apology.

Six families, one question: whose fault?

  • Rejection — the user’s. Bad input, missing file. No retry. Exit 1. Tone: instructional.
  • Conflict — also the user’s, but they have to untangle something first. HTTP 409.
  • Transient — the system’s. Retry away. Exit 75, EX_TEMPFAIL, from BSD’s sysexits.h, 1987. The most modern part of your error handling is almost forty years old.
  • Corruption — the data is damaged; fault is a luxury. Urgent. Do not retry. Do not pass Go, do not collect 200.
  • Infrastructure — the system’s again, but retrying won’t help. Exit 69, EX_UNAVAILABLE. Tone: apologetic.
  • Orchestration — yours. Your own program has a bug. Apologetic too, but to your on-call, not your user.
err := errors.New("connection refused")

errorfamily.Classify(err)    // Transient (unknown errors fail open)
errorfamily.IsRetryable(err) // true
errorfamily.ExitCode(err)    // 75

Join a Transient and a Corruption with errors.Join and Classify picks Corruption — worst severity wins, deterministically. A partial failure never gets to cosplay as a success.

The default is Transient, on purpose

An error nobody classified counts as retryable. Fail-open. The reasoning: an error you know nothing about is more often the network’s fault than the user’s, and a retry that turned out unnecessary is cheaper than a request that never got one. You can disagree — I went back and forth — but a default is a decision, and I would rather make mine loudly than pretend a library gets to not have one.

What, Why, Fix, WayOut

A classified error carries a structured user message: what happened, why, how to fix it, and the way out.

os.Exit(errorfamily.HandleError(err))
// stderr: A required resource was not found.
//         Check that the path and resource name are correct.
// exit: 1

Four sentences, written in advance, so the 2 a.m. version of your user gets an answer instead of a stack trace.

What I deliberately did not build

A retry loop. IsRetryable is a signal; backoff, jitter, and idempotency are yours. The moment a library runs the loop, it owns your latency budget.

Stack traces. go-error-family classifies; samber/oops enriches. Libraries import only the protocol; applications pick their own observability stack. Share the protocol, not the implementation.

A base type you must adopt. The Error struct is a reference implementation, not a requirement. Your own error type implements ErrorFamily() and everything — Classify, ExitCode, HTTPStatus, HandleError — works.

The linter that never existed

In v0.10.0 I removed 52 //nolint:hierarchical-errors directives across 13 files. The linter was never installed. Not as a binary, not as a golangci-lint plugin, not as anything. Fifty-two comments defending against a reviewer that did not exist — each one emitting an “unknown linter” warning on every single run. I was haunted by a ghost I had hired myself, and the ghost left a paper trail.

I would love to blame an AI agent. One probably wrote some of them. I merged all of them.

The same release fixed a phantom replace directive in the examples module, pointing at version v0.0.0-00010101000000-000000000000 — a release timestamped in the year 1. Go strips replace directives on fetch, so every consumer would have hit an unresolvable module graph two millennia in the making. The changelog notes, with the calm of a man reading his own autopsy: “Same class of bug as the v0.6.0 hotfix.”

Where it stands

v0.10.0. Go 1.26+, because errors.AsType is how classification should work. Zero third-party dependencies in the root module. Six families, sysexits exit codes, HTTP mapping, diagnostic rules that find out why PostgreSQL said no, and a test-helpers package so you can assert all of it. Docs at errorfamily.lars.software.

Seven go.mod files move in lockstep — every release is a small ceremony. One GitHub star. I checked. It is mine.

Your errors already know whose fault they are. Now your code can too.