if err != nil ist die meistgeschriebene Zeile in Go. Wir machen Witze darüber, wir memen darüber, wir tippen sie tausende Male pro Codebase. Und die Zeile ist okay. Das Problem ist die Zeile danach.
Und dann? Soll der Caller retrien? Welchen Exit-Code soll das CLI zurückgeben? Was sagst du dem User? In den meisten Codebases sind die ehrlichen Antworten:
if strings.Contains(err.Error(), "timeout") {
// retrien? vermutlich? der String hat es gesagt
}
fmt.Println(err) // "pgconn: conn busy" — danke, sehr actionable
os.Exit(1) // Datei nicht gefunden: 1. Festplatte in Flammen: auch 1
String-Matching auf eine Message, die nie ein Vertrag war. Ein Exit-Code für jeden Failure-Mode. Interner Jargon, einem User in die Hand gedrückt, als wäre er eine Erklärung. Ich habe alle drei geschrieben. Statistisch gesehen du auch.
Also baute ich go-error-family. Eine Idee: Ein Error sollte wissen, wessen Schuld er ist — und alles andere sollte daraus folgen. Die Retry-Entscheidung. Der Exit-Code. Der HTTP-Status. Der Ton der Entschuldigung.
Sechs Familien, eine Frage: wessen Schuld?
- Rejection — die des Users. Bad Input, fehlende Datei. Kein Retry. Exit 1. Ton: instructional.
- Conflict — auch die des Users, aber er muss erst etwas entwirren. HTTP 409.
- Transient — die des Systems. Retrien erlaubt. Exit 75,
EX_TEMPFAIL, aus BSDs sysexits.h, 1987. Der modernste Teil deines Error-Handlings ist fast vierzig Jahre alt. - Corruption — die Daten sind beschädigt; Schuld ist ein Luxus. Urgent. Nicht retrien. Nicht über Los gehen, keine 200 einsammeln.
- Infrastructure — wieder das System, aber Retrien hilft nicht. Exit 69,
EX_UNAVAILABLE. Ton: apologetic. - Orchestration — deine. Dein eigenes Programm hat einen Bug. Ebenfalls apologetic, aber gegenüber deinem On-Call, nicht deinem User.
err := errors.New("connection refused")
errorfamily.Classify(err) // Transient (unbekannte Errors failen offen)
errorfamily.IsRetryable(err) // true
errorfamily.ExitCode(err) // 75
Joine einen Transient und eine Corruption mit errors.Join, und Classify wählt Corruption — die schlimmste Severity gewinnt, deterministisch. Ein Partial Failure darf nie als Erfolg cosplayen.
Der Default ist Transient, absichtlich
Ein Error, den niemand klassifiziert hat, gilt als retryable. Fail-open. Die Logik: Ein Error, über den du nichts weißt, ist öfter die Schuld des Netzwerks als die des Users, und ein Retry, der sich als unnötig herausstellt, ist billiger als ein Request, der nie einen bekommen hat. Du kannst anderer Meinung sein — ich bin hin- und hergegangen — aber ein Default ist eine Entscheidung, und ich treffe meine lieber laut, als so zu tun, als dürfte eine Library keine haben.
What, Why, Fix, WayOut
Ein klassifizierter Error trägt eine strukturierte User-Message: was passiert ist, warum, wie man es fixt, und den Ausweg.
os.Exit(errorfamily.HandleError(err))
// stderr: A required resource was not found.
// Check that the path and resource name are correct.
// exit: 1
Vier Sätze, im Voraus geschrieben, damit die 2-Uhr-morgens-Version deines Users eine Antwort bekommt statt eines Stack Traces.
Was ich bewusst nicht gebaut habe
Einen Retry-Loop. IsRetryable ist ein Signal; Backoff, Jitter und Idempotenz sind deins. In dem Moment, in dem eine Library den Loop fährt, besitzt sie dein Latency-Budget.
Stack Traces. go-error-family klassifiziert; samber/oops reichert an. Libraries importieren nur das Protokoll; Applications wählen ihren eigenen Observability-Stack. Teile das Protokoll, nicht die Implementierung.
Einen Basistyp, den du übernehmen musst. Das Error-Struct ist eine Referenzimplementierung, keine Pflicht. Dein eigener Error-Typ implementiert ErrorFamily(), und alles — Classify, ExitCode, HTTPStatus, HandleError — funktioniert.
Der Linter, der nie existierte
In v0.10.0 entfernte ich 52 //nolint:hierarchical-errors-Direktiven über 13 Dateien. Der Linter war nie installiert. Nicht als Binary, nicht als golangci-lint-Plugin, nicht als irgendwas. Zweiundfünfzig Kommentare, die sich gegen einen Reviewer verteidigten, der nicht existierte — jeder einzelne emittierte eine “unknown linter”-Warnung bei jedem einzelnen Lauf. Ich wurde von einem Geist heimgesucht, den ich selbst angeheuert hatte, und der Geist hinterließ eine Papierspur.
Ich würde das gerne einem AI Agent in die Schuhe schieben. Einer hat vermutlich einige davon geschrieben. Ich habe alle gemergt.
Dasselbe Release fixte eine Phantom-replace-Direktive im Examples-Modul, die auf Version v0.0.0-00010101000000-000000000000 zeigte — ein Release, getimestampt im Jahr 1. Go strippt replace-Direktiven beim Fetchen, also hätte jeder Consumer einen unauflösbaren Module Graph getroffen, zwei Jahrtausende in der Mache. Das Changelog vermerkt, mit der Ruhe eines Mannes, der seine eigene Autopsie liest: “Same class of bug as the v0.6.0 hotfix.”
Wo es steht
v0.10.0. Go 1.26+, weil errors.AsType ist, wie Klassifizierung funktionieren sollte. Zero Third-Party-Dependencies im Root-Modul. Sechs Familien, sysexits-Exit-Codes, HTTP-Mapping, Diagnostic Rules, die herausfinden, warum PostgreSQL nein gesagt hat, und ein Test-Helpers-Package, damit du all das asserten kannst. Docs auf errorfamily.lars.software.
Sieben go.mod-Dateien bewegen sich im Gleichschritt — jedes Release ist eine kleine Zeremonie. Ein GitHub-Star. Ich habe nachgeschaut. Er ist meiner.
Deine Errors wissen bereits, wessen Schuld sie sind. Jetzt kann dein Code es auch.