Skip to content

HTTP & CLI Boundaries

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.

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)

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.",
},
},
})

Messages resolve in this order (first match wins):

  1. HandleConfig.TemplateOverride[code] — per-call override
  2. RegisterTemplate(code, tmpl) — global registry
  3. Built-in defaultMessages[code] — exact-match defaults
  4. Family.DefaultMessage() — generic fallback

All lookups are exact code matches (case-insensitive). Template placeholders use {key} syntax.

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-validate
func 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))

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."
}
Family HTTP Status
Rejection 400
Conflict 409
Transient 503
Corruption 500
Infrastructure 503
Orchestration 500

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(),
})
}
}

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) == 1

Families 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().

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
}