HTTP & CLI Boundaries
CLI Boundary: HandleError
Section titled “CLI Boundary: HandleError”HandleError is the top-of-main handler that classifies the error, picks an exit code, formats a user-facing message, and writes to stderr:
func main() { if err := run(); err != nil { os.Exit(errorfamily.HandleError(err)) }}The output follows a structured format: What / Why / Fix / WayOut.
Structured Result (No stderr Write)
Section titled “Structured Result (No stderr Write)”For HTTP handlers, gRPC interceptors, or programmatic use:
result := errorfamily.HandleErrorDetailed(err)fmt.Printf("exit=%d msg=%q fix=%q\n", result.ExitCode, result.Message, result.SuggestedFix)Context-Propagating Handler
Section titled “Context-Propagating Handler”The canonical entry point when you have a context.Context:
exitCode := errorfamily.HandleErrorWithContext(ctx, err, errorfamily.HandleConfig{ DiagnosticFunc: myDiagnoseFunc, Output: myWriter, TemplateOverride: map[string]errorfamily.MessageTemplate{ "file.not_found": { What: "Could not find {path}", Why: "The file doesn't exist at the expected location.", Fix: "Check that {path} exists and is readable.", WayOut: "Run with --verbose for more details.", }, },})Template Resolution
Section titled “Template Resolution”Messages resolve in this order (first match wins):
HandleConfig.TemplateOverride[code]— per-call overrideRegisterTemplate(code, tmpl)— global registry- Built-in
defaultMessages[code]— exact-match defaults Family.DefaultMessage()— generic fallback
All lookups are exact code matches (case-insensitive). Template placeholders use {key} syntax.
HTTP Boundary
Section titled “HTTP Boundary”HTTPStatus(err) maps any error to its status code. HTTPHandler wraps an error-returning handler and writes a safe JSON response (no internal leakage):
// skip-validatefunc createOrder(w http.ResponseWriter, r *http.Request) error { order, err := parseOrder(r) if err != nil { return errorfamily.WrapRejectionf(err, "order.parse", "invalid field") } if err := repo.Save(order); err != nil { return errorfamily.WrapConflict(err, "order.duplicate", "order already exists") } json.NewEncoder(w).Encode(order) return nil}
mux.Handle("/api/orders", errorfamily.HTTPHandler(createOrder))Safe JSON Responses
Section titled “Safe JSON Responses”HTTPHandler never leaks err.Error(). The response body contains only family, code, and a user-facing message from a registered template:
{ "family": "conflict", "code": "order.duplicate", "message": "A conflict was detected."}Status Code Mapping
Section titled “Status Code Mapping”| Family | HTTP Status |
|---|---|
| Rejection | 400 |
| Conflict | 409 |
| Transient | 503 |
| Corruption | 500 |
| Infrastructure | 503 |
| Orchestration | 500 |
Custom Response Shape
Section titled “Custom Response Shape”For a custom response, write your own response and use HTTPStatus(err) directly:
func handler(w http.ResponseWriter, r *http.Request) { err := doWork(r) if err != nil { status := errorfamily.HTTPStatus(err) w.WriteHeader(status) json.NewEncoder(w).Encode(map[string]any{ "error": errorfamily.Code(err), "type": errorfamily.Classify(err).String(), }) }}Conditional Requests (304, 412, 428, 416)
Section titled “Conditional Requests (304, 412, 428, 416)”Every family maps to an error status (4xx/5xx), so conditional-request outcomes (RFC 9110 §13) need explicit handling:
| Outcome | Classification |
|---|---|
| 304 Not Modified | Not an error. Write the 304 (plus ETag/Cache-Control) yourself and return nil — HTTPHandler treats nil as “fully handled”. Never classify it. |
| 412 Precondition Failed (RFC 9110 §15.5.13) | Conflict + WithHTTPStatus(412). The client asserted state (If-Match: "abc") that no longer holds — a version mismatch. Remediation: refresh and reapply. |
| 428 Precondition Required (RFC 6585 §3) | Rejection + WithHTTPStatus(428). The request omitted a required precondition — incomplete input, not a state clash. Remediation: fix the request. |
| 416 Range Not Satisfiable (RFC 9110 §15.5.17) | Rejection + WithHTTPStatus(416). The Range value doesn’t overlap the resource — a bad input value. |
func getWidget(w http.ResponseWriter, r *http.Request) error { // RFC 9110 §13.2.2: If-Match evaluates before If-None-Match if im := r.Header.Get("If-Match"); im != "" && !etagMatches(im, currentETag) { return errorfamily.NewConflict("etag.precondition_failed", "If-Match precondition failed"). WithHTTPStatus(http.StatusPreconditionFailed) // 412, not the family default 409 } if etagMatches(r.Header.Get("If-None-Match"), currentETag) { w.Header().Set("ETag", currentETag) w.WriteHeader(http.StatusNotModified) // success outcome — no error, no body return nil } // ...serve the current representation return nil}
mux.Handle("/widgets/{id}", errorfamily.HTTPHandler(getWidget))// HTTPStatus(err) == 412 · Classify(err) == Conflict · IsRetryable(err) == false · ExitCode(err) == 1Families stay behavioral (retry, exit code, tone); statuses stay wire-level. WithHTTPStatus is the intended seam — the same pattern as a 404 Not Found Rejection. For error types you don’t build with this package, implement the HTTPStatuser interface instead. To control the client-facing message in HTTPHandler responses, register a template for the code — HTTPHandler never leaks err.Error().
Structured Logging
Section titled “Structured Logging”LogError logs classified fields at Warn for Transient and Error for everything else:
if err := run(); err != nil { errorfamily.LogError(err, slog.Default()) // → level=WARN msg="db timeout" family=transient code=db.timeout retryable=true}