errorfamily

package module
v0.12.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: MIT Imports: 15 Imported by: 192

README

go-error-family

Go Reference Go Report Card

Structured error protocol for Go — behavioral classification, exit codes, and diagnostic rules.

Share the protocol, not the implementation.

Documentation · pkg.go.dev · Changelog


The Problem

Every Go program has if err != nil. But what do you do with that error?

Question Before After
Should the caller retry? if strings.Contains(err.Error(), "timeout") errorfamily.IsRetryable(err)
What exit code? os.Exit(1) for everything errorfamily.ExitCode(err) — context-aware
What message for the user? fmt.Println(err) — often internal jargon Structured What/Why/Fix/WayOut
Is it the user's fault or the system's? Guess from the string errorfamily.Classify(err).Audience()

go-error-family answers all of these with a single concept: Family.

Installation

go get github.com/larsartmann/go-error-family

Requires Go 1.26+ (uses errors.AsType). Zero third-party dependencies.

This is a Go workspace. The root module (classification core, zero third-party deps) is stable. Experimental submodules — agent, bridge (samber/oops integration), diagnose, diagnose/git, diagnose/postgres — have their own go.mod and require separate imports.

Complementary, not competing

go-error-family classifies; samber/oops enriches (stack traces, trace IDs). Use both:

  • Libraries import go-error-family only — they know their domain contract (a 404 is a Rejection, a timeout is Transient) but must not presume the app's observability stack, so they never import oops.
  • Applications import oops for enrichment and, if they also need behavioral decisions, wrap library errors via the bridge/ package.

Running the two together: three patterns (pass through, AutoWrap, explicit Wrap) in the bridge guide.

The six interfaces (Coded, Classified, Contextual, Retryable, ExitCoder, HTTPStatuser) are the sole public contract; the Error struct is a reference implementation, not a requirement.

Quick Start

package main

import (
    "errors"
    "os"

    "github.com/larsartmann/go-error-family"
)

func main() {
    err := errors.New("connection refused")

    family := errorfamily.Classify(err)
    // → Transient (default: unknown errors are retryable)

    if errorfamily.IsRetryable(err) {
        // yes — retrying is appropriate (backoff/jitter/idempotency are yours to implement)
    }

    os.Exit(errorfamily.ExitCode(err))
    // → 75 (EX_TEMPFAIL: temporary failure)
}

For your own errors, attach a Family at creation time:

err := errorfamily.NewRejection("file.not_found", "config file missing").
    WithContext("path", "/etc/app/config.yaml")

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

See examples/ for runnable CLI, HTTP, custom diagnostic rule, and oops-bridge demos.

What It Gives You

  • Family — behavioral classification (Rejection, Conflict, Transient, Corruption, Infrastructure, Orchestration) that maps to retry decisions, exit codes, HTTP status codes, and user-facing tone
  • Small interfaces — Coded, Classified, Contextual, Retryable, ExitCoder, HTTPStatuser — each error type implements what it needs; the Error struct is just a reference implementation
  • Classify(err) — universal classification for any error (multi-error → interface → sentinels → classifiers → default)
  • Multi-error support — errors.Join + Classify picks the worst Family by severity, deterministically regardless of argument order
  • ExitCode(err) — BSD sysexits.h exit codes derived from Family (overridable per-error via ExitCoder interface)
  • Code(err) — one-liner error-code extraction (walks the unwrap chain for the Coded interface)
  • IsRetryable(err) — binary retry decision derived from Family
  • Family.HTTPStatus() — canonical family→HTTP status mapping (Rejection→400, Conflict→409, Transient→503, …)
  • HTTPStatus(err) / HTTPHandler(fn) — classify→status-code helper and a ready-made net/http middleware writing safe JSON responses
  • Family.RetryPolicy() — advisory retry defaults (attempts + backoff); the library does not run the loop
  • Error.JSON() — canonical JSON view for API boundaries
  • RegisterClassifier(func(error) (Family, bool)) — predicate-based classification for dynamic third-party errors (e.g. *sqlite.Error) that can't be registered as sentinels
  • Registry — injectable registry with Clone() for inherit-and-extend; test isolation and scoped error handling (no t.Cleanup needed)
  • RegisterStdlibDefaults(reg) — pre-registered classifications for common stdlib errors (context/sql/os) with documented rationale
  • TemplateForCode(code) — look up a registered message template without the full CLI pipeline (for HTTP/gRPC boundaries)
  • LogError(err, logger) — structured log/slog logging with family/code/retryable/context fields
  • errorfamilytest — test assertion helpers (AssertFamily, AssertCode, AssertRetryable, AssertContext, AssertContextMissing, AssertExitCode, AssertHTTPStatus), each with a godoc example
  • HandleError(err) — CLI boundary handler with structured messages (What / Why / Fix / WayOut)
  • Diagnostic rules — deterministic checks (PostgreSQL, filesystem, network, git) that auto-discover why an error occurred and emit structured Fix{Summary, Command}; the RuleSpec data-driven pattern has a runnable godoc example
  • WrapOnce(err, family, code, msg) — idempotent wrap that prevents double-wrapping at API boundaries
  • WithExitCode(code) / WithContextAny(key, value) — per-error exit code override and type-safe context attachment

The Six Families

Family Retryable Exit Code Whose Fault Tone
Rejection no 1 User Instructional
Conflict no 1 User (needs to resolve) Explanatory
Transient yes 75 System Reassuring
Corruption no 65 Data damage Urgent
Infrastructure no 69 System Apologetic
Orchestration no 70 Program (internal bug) Apologetic

Each family exposes Audience() (User / Ops / All) and Tone() for presentation-layer decisions.

Constructors

// Simple
err := errorfamily.NewRejection("config.invalid", "bad config")

// With cause
err := errorfamily.WrapTransient(dbErr, "db.timeout", "query timed out")

// With context (chainable)
err := errorfamily.NewRejection("file.not_found", "config missing").
    WithContext("path", "/etc/app/config.yaml").
    WithContext("format", "yaml")

// Formatted
err := errorfamily.Newf(errorfamily.Rejection, "file.not_found", "missing: %s", path)

// Multi-error (partial success) — use stdlib errors.Join, Classify picks the worst Family
err := errors.Join(err1, err2, err3)

Family-specific constructors: NewRejection, NewConflict, NewTransient, NewCorruption, NewInfrastructure, NewOrchestration. Wrap variants: WrapRejection, WrapConflict, WrapTransient, WrapCorruption, WrapInfrastructure, WrapOrchestration. Formatted wrap variants: WrapRejectionf, WrapConflictf, WrapTransientf, WrapCorruptionf, WrapInfrastructuref, WrapOrchestrationf.

Classification

// Classify any error → Family
family := errorfamily.Classify(err)

// Retry decision — binary signal only; backoff/idempotency are your responsibility
if errorfamily.IsRetryable(err) {
    retry(err)
}

// Exit code for CLI
os.Exit(errorfamily.ExitCode(err))

// Parse from string (e.g. config, HTTP headers)
family := errorfamily.ParseFamily("transient") // defaults to Transient for unknowns

Classification precedence — first match wins:

  1. Multi-error (errors.Join) → classify each sub-error, worst severity wins
  2. Classified interface → ErrorFamily()
  3. Retryable interface → infer Transient (true) or Rejection (false)
  4. Registered sentinels via errors.Is chain walk
  5. Registered classifiers (RegisterClassifier) — predicate-based, for dynamic errors
  6. Default → Transient (fail-open for retry)

Registering Third-Party Errors

Decision tree: which classification mechanism?
Do you OWN the error type?
├── YES → Implement the Classified interface (ErrorFamily()) on your type.
├         (Or use NewRejection/NewConflict/.../WrapRejection/... constructors.)
└── NO  → Is it a SENTINEL (a single, stable value compared by errors.Is)?
          ├── YES → RegisterClassification(sentinel, family)
          └── NO  → Is it a DYNAMIC type (new instance per error, e.g. *sqlite.Error)?
                    ├── YES → RegisterClassifier(func(error) (Family, bool))
                    └── NO  → Let Classify default to Transient (fail-open for retry)
Sentinels (stable values)

For errors you don't own that are stable sentinel values (stdlib, libraries):

func init() {
    errorfamily.RegisterClassification(sql.ErrConnDone, errorfamily.Transient)
    errorfamily.RegisterClassification(os.ErrPermission, errorfamily.Rejection)

    // Batch registration
    errorfamily.RegisterClassifications(map[error]errorfamily.Family{
        sql.ErrConnDone: errorfamily.Transient,
        sql.ErrTxDone:   errorfamily.Transient,
    })
}
Classifiers (dynamic errors)

Some third-party errors are dynamic — each occurrence is a fresh instance (e.g. *sqlite.Error, *pgconn.PgError), so they can't be matched by errors.Is identity. Register a predicate-based classifier instead:

func init() {
    errorfamily.RegisterClassifier(func(err error) (errorfamily.Family, bool) {
        var sqliteErr *sqlite.Error
        if errors.As(err, &sqliteErr) {
            switch sqliteErr.Code() {
            case 5, 6: return errorfamily.Transient, true // BUSY, LOCKED
            case 19:   return errorfamily.Conflict, true  // CONSTRAINT
            }
        }
        return errorfamily.Transient, false
    })
}

Classifiers run only after sentinels miss, in registration order; the first returning ok=true wins. For test isolation, construct a NewRegistry() and call its RegisterClassifier method instead of polluting DefaultRegistry.

Implementing Your Own Error Type

You don't have to use the built-in Error struct. Implement the interfaces you need:

package mypkg

import (
    "fmt"

    "github.com/larsartmann/go-error-family"
)

type FindingError struct {
    Category string
    Message  string
    File     string
    Line     int
    Cause    error
}

func (e *FindingError) Error() string          { return e.Message }
func (e *FindingError) Unwrap() error          { return e.Cause }
func (e *FindingError) ErrorCode() string      { return e.Category }
func (e *FindingError) ErrorFamily() errorfamily.Family {
    switch e.Category {
    case "validation":
        return errorfamily.Rejection
    case "io":
        return errorfamily.Transient
    default:
        return errorfamily.Infrastructure
    }
}
func (e *FindingError) ErrorContext() map[string]string {
    return map[string]string{"file": e.File, "line": fmt.Sprintf("%d", e.Line)}
}

Now Classify(), IsRetryable(), ExitCode(), and HandleError() all work with your type.

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

For more control:

// Structured result (no stderr write) — for HTTP handlers, gRPC interceptors
result := errorfamily.HandleErrorDetailed(err)
fmt.Printf("exit=%d msg=%q fix=%q\n", result.ExitCode, result.Message, result.SuggestedFix)

// Template-aware structured result
result := errorfamily.HandleErrorDetailedWithConfig(err, cfg)

// Context-propagating handler — preferred when you have a context.Context
exitCode := errorfamily.HandleErrorWithContext(ctx, err, errorfamily.HandleConfig{
    Output:      myWriter,
    DiagnosticFunc: myDiagnoseFunc,
    OnDiagnosed: func(err error, findings []errorfamily.DiagnosticFinding) {
        logFindings(findings)
    },
    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

Messages are resolved in this order (first match wins):

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

All lookups are exact code matches (case-insensitive). No substring matching.

Register templates globally for reusable error codes:

errorfamily.RegisterTemplate("db.timeout", errorfamily.MessageTemplate{
    What: "Database connection timed out.",
    Why:  "The database server did not respond within the deadline.",
    Fix:  "Check database health and network connectivity.",
})

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):

func createOrder(w http.ResponseWriter, r *http.Request) error {
    order, err := parseOrder(r)
    if err != nil {
        return errorfamily.WrapRejectionf(err, "order.parse", "invalid field %s", field)
    }
    if err := repo.Save(order); err != nil {
        return errorfamily.WrapConflict(err, "order.duplicate", "order already exists")
    }
    json.NewEncoder(w).Encode(order)
    return nil
}

func registerRoutes(mux *http.ServeMux) {
    mux.Handle("/api/orders", errorfamily.HTTPHandler(createOrder))
}
// Rejection → 400, Conflict → 409, Transient → 503, ...

The response body contains only family, code, and a user-facing message (from a registered template) — never the raw err.Error():

{
  "family": "conflict",
  "code": "order.duplicate",
  "message": "A conflict was detected."
}

For a custom response shape, write your own response and use HTTPStatus(err) directly.

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". Classifying it would attach wrong retry/exit semantics.
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. Not retryable; remediation is "refresh and reapply", exactly Conflict's.
428 Precondition Required (RFC 6585 §3) Rejection + WithHTTPStatus(428). The request omitted a required precondition — incomplete input, not a state clash. Remediation is "fix the request", exactly Rejection's.
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
}

func registerWidgetRoute(mux *http.ServeMux) {
    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 between them — the same pattern as a 404 Not Found Rejection. If you don't construct errors with this package, implement the HTTPStatuser interface on your own type instead. To control the client-facing message in HTTPHandler responses, register a template for the code — HTTPHandler never leaks err.Error().

For gRPC services, the gRPC status-mapping guide maps the same six families to gRPC status codes, with a ready interceptor pattern.

Structured Logging

LogError logs classified fields (family, code, retryable, and each context key prefixed with context.) at Warn for Transient errors 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
}

LogErrorContext(ctx, err, logger) propagates a context for trace correlation.

Test Helpers

The errorfamilytest subpackage mirrors net/http/httptest — it keeps testing out of the production package. Every helper has a runnable-shaped godoc example on pkg.go.dev (ExampleAssertFamily and friends):

import "github.com/larsartmann/go-error-family/errorfamilytest"

func TestHandler(t *testing.T) {
    err := handler(req)
    errorfamilytest.AssertFamily(t, err, errorfamily.Rejection)
    errorfamilytest.AssertCode(t, err, "user.not_found")
    errorfamilytest.AssertRetryable(t, err, false)
    errorfamilytest.AssertContext(t, err, "user_id", "42")
}

Diagnostic Rules

Auto-discover why an error occurred:

runner := diagnose.DefaultRunner()
results := runner.Run(ctx, err)

for _, r := range results {
    if r.Status == diagnose.StatusFailed {
        fmt.Println(r.Summary)
        fmt.Println("  Fix:", r.Fix.Summary)
        if r.Fix.Command != "" {
            fmt.Println("  Run:", r.Fix.Command)
        }
    }
}

Built-in rules (zero-dependency, included in DefaultRunner):

  • FilesystemRule — checks path existence, permissions, writability
  • NetworkRule — checks DNS resolution, TCP connectivity

Opt-in submodules (import explicitly):

import (
    "github.com/larsartmann/go-error-family/diagnose/git"
    "github.com/larsartmann/go-error-family/diagnose/postgres"
)

runner := diagnose.NewRunner(&git.GitRule{}, &postgres.PostgresRule{}, &diagnose.FilesystemRule{})
  • GitRule (diagnose/git) — checks repo state, merge conflicts, remote reachability
  • PostgresRule (diagnose/postgres) — checks pg_isready, TCP connectivity

Results include Confidence (0.0–1.0) and are sorted by confidence descending.

Writing Custom Rules

Use RuleSpec for data-driven matching — the simplest and most common pattern:

type RateLimitRule struct{}

const keyRetryAfter diagnose.ContextKey = "retry_after"

var rateLimitSpec = diagnose.RuleSpec{
    ContextKeys:  []diagnose.ContextKey{keyRetryAfter},
    CodeContains: []string{"rate.limit", "too_many_requests"},
}

func (r *RateLimitRule) Name() string              { return "rate_limit" }
func (r *RateLimitRule) Applicable(err error) bool { return rateLimitSpec.Matches(err) }

func (r *RateLimitRule) Run(ctx context.Context, err error) (*diagnose.DiagnosticResult, error) {
    retryAfter := diagnose.ResolveContextKey(err, []string{"retry_after"}, "unknown")
    // ... check system state ...
    return &diagnose.DiagnosticResult{
        Status:     diagnose.StatusDegraded,
        Confidence: diagnose.ConfidenceHigh,
        Summary:    "Rate limited — retry after " + retryAfter,
        Fix: diagnose.Fix{
            Summary: "Wait for the duration specified in the Retry-After header",
        },
    }, nil
}

For testable rules that shell out, accept a CommandRunner:

type MyRule struct {
    Runner diagnose.CommandRunner // inject mock in tests; defaults to DefaultCommandRunner{}
}

See the custom rule example for a complete working example.

AI Debug Agent

Root cause analysis and fix suggestions from diagnostic context:

ag := agent.New(agent.Config{Enabled: true})
result, _ := ag.Analyze(ctx, err, diagnosis)

fmt.Println("Root cause:", result.RootCause)
fmt.Println("Confidence:", result.Confidence)
for _, step := range result.FixSteps {
    fmt.Printf("  - %s\n    Command: %s\n", step.Description, step.Command)
}

The agent produces analysis but does not execute fixes — the consumer decides what to do with FixStep.Command.

AgentResult fields: RootCause, Confidence, Explanation, FixSteps.

Performance

Zero-allocation hot paths. Benchmarks on AMD Ryzen 9 7950X:

Operation Time Allocs
Classify (built-in Error) ~9 ns 0
Classify (plain error) ~30 ns 0
IsRetryable ~9 ns 0
ExitCode ~9 ns 0
WithContext ~8 ns 0
ParseFamily ~12 ns 0
HandleError (with context) ~450 ns 5
Runner.Run (1 rule) ~420 ns 5

HandleError includes template resolution and stderr write. Use HandleErrorDetailed when you only need the structured result.

Architecture

go-error-family/
├── family.go               — Family enum + data-driven familyData
├── interfaces.go           — Coded, Classified, Contextual, Retryable, ExitCoder, HTTPStatuser (each embeds error)
├── error.go                — Reference Error struct (Is, Unwrap, Format, WithContext, accessors)
├── classify.go             — Classify, Code, IsRetryable, ExitCode, Classifier, RegisterClassifier(s)
├── constructors.go         — New, Wrap, Newf, Wrapf + family-specific shortcuts (incl. Wrap{Family}f)
├── registry.go             — injectable Registry (sentinels, classifiers, templates), Clone, DefaultRegistry
├── stdlib.go               — RegisterStdlibDefaults (context/sql/os error taxonomy)
├── handle.go               — HandleError, HandleErrorWithContext, template system, TemplateForCode
├── http.go                 — HTTPStatus, HTTPHandler (classify→status-code net/http middleware)
├── log.go                  — LogError, LogErrorContext (structured slog logging)
├── errorfamilytest/        — test assertion helpers (AssertFamily, AssertCode, ...)
├── diagnose/
│   ├── diagnose.go         — Runner, DiagnosticRule, RuleSpec, CommandRunner, ContextKey
│   ├── command.go          — RunCommand, CommandExists (exported for rule authors)
│   ├── rules_filesystem.go — FilesystemRule
│   ├── rules_network.go    — NetworkRule
│   ├── git/                — submodule: GitRule
│   └── postgres/           — submodule: PostgresRule
├── agent/
│   └── agent.go            — DebugAgent interface, Config, AgentResult, FixStep
├── bridge/                 — submodule: samber/oops integration (Wrap, AutoWrap, InferFamily)
└── examples/
    ├── cmd/cli             — CLI boundary handler example
    ├── cmd/http            — HTTP middleware with status code mapping
    ├── cmd/custom_rule     — Writing your own DiagnosticRule
    ├── cmd/bridge          — oops + bridge reference implementation (3 patterns)
    └── checkout/           — library layer that imports only errorfamily

When to Use / When Not To

Use this when you need behavior derived from errors: retry decisions, exit codes, user-facing messages, or diagnostic investigation — especially at program boundaries (CLI top-level, HTTP handlers, gRPC interceptors).

Don't use this when a simple fmt.Errorf("...: %w", err) is enough. If you only need to wrap and propagate, the standard library is fine. This library shines when errors need to drive behavior.

Philosophy

  1. Protocol, not framework — share the vocabulary, not the implementation
  2. Each error type implements what it needs — no god struct
  3. Presentation is separate from the error — the CLI layer formats for humans
  4. Family = exit code = retry decision = tone — one concept, many audiences
  5. Generic errors are structurally impossible — Code + Family + Context guarantee specificity

License

MIT — Copyright (c) 2026 Lars Artmann

Documentation

Overview

Package errorfamily provides structured error classification and handling for Go. Every error gets a behavioral Family (retry/no-retry), a machine-readable code, human-readable context, and optional diagnostics. Designed for CLI and HTTP boundaries.

The contract is the interfaces, not the struct

The public protocol is four small interfaces in interfaces.go: Coded, Classified, Contextual, and Retryable. Each error type implements the combination that makes sense for it; consumers ask for exactly what they need via errors.AsType[T].

The concrete Error type is a reference implementation — convenient for simple needs, but not required. Projects with domain-specific error shapes (a finding with File/Line, an HTTP error with Status) build their own struct and implement only the interfaces they need. Share the protocol, not the implementation.

Stability

The root package (classification core) is the stable foundation. The agent, diagnose, and bridge packages are experimental (v0.x).

Example (ConditionalRequests)

Conditional requests (RFC 9110 §13): families are behavioral, statuses are wire-level — WithHTTPStatus is the seam. A 304 is a success outcome, never a classified error: write it and return nil.

// 412 Precondition Failed: the client's If-Match ETag no longer matches
// the current state — a version mismatch, so Conflict.
err := NewConflict("etag.precondition_failed", "If-Match precondition failed").
	WithHTTPStatus(412)
fmt.Println(HTTPStatus(err), Classify(err), IsRetryable(err), ExitCode(err))

// 428 Precondition Required (RFC 6585 §3): the request omitted a required
// precondition — incomplete input, so Rejection.
err = NewRejection("etag.precondition_required", "conditional request required").
	WithHTTPStatus(428)
fmt.Println(HTTPStatus(err), Classify(err), IsRetryable(err), ExitCode(err))
Output:
412 conflict false 1
428 rejection false 1

Index

Examples

Constants

This section is empty.

Variables

View Source
var DefaultRegistry = NewRegistry()

DefaultRegistry is the package-level Registry used by the convenience functions (Classify, RegisterClassification, RegisterTemplate, etc.) and by HandleError when HandleConfig.Registry is nil.

Functions

func Code added in v0.6.0

func Code(err error) string

Code extracts the machine-readable error code from any error in the chain. It walks the unwrap chain via errors.AsType looking for the Coded interface. Returns "" if the error (or any wrapped cause) does not implement Coded.

This is the one-liner replacement for:

var coded errorfamily.Coded
if errors.As(err, &coded) {
    code = coded.ErrorCode()
}

func Compose added in v0.3.0

func Compose(
	errs ...error,
) error

Compose joins multiple errors into one, preserving all in the Unwrap chain. It is a thin wrapper around errors.Join kept for backward compatibility with consumers that imported Compose before v0.5.0 reorganized the package.

func ExitCode

func ExitCode(err error) int

ExitCode returns the appropriate process exit code for an error. Nil errors return 0.

If the error (or any error in its chain) implements ExitCoder with a non-zero code, that code is returned — allowing individual errors to override their family's canonical exit code. Otherwise, the error is classified and mapped to BSD sysexits.h codes via Family.ExitCode.

Example
// ExitCode checks the ExitCoder interface first (per-error override),
// then falls back to the family's canonical exit code.
fmt.Println(ExitCode(NewTransient("db.timeout", "msg")))
fmt.Println(ExitCode(NewRejection("bad.input", "msg")))
fmt.Println(ExitCode(NewTransient("custom", "msg").WithExitCode(5)))
Output:
75
1
5

func HTTPHandler added in v0.6.0

func HTTPHandler(h HandlerFunc) http.Handler

HTTPHandler wraps a HandlerFunc, classifying any returned error and writing a JSON error response with the status code from Family.HTTPStatus.

mux.Handle("/api/orders", errorfamily.HTTPHandler(createOrder))

The response body is intentionally safe — it never leaks the raw error message. It contains:

  • "family": the behavioral family (e.g. "rejection")
  • "code": the machine-readable code (omitted when empty)
  • "message": a user-facing message from a registered MessageTemplate (omitted when no template is registered for the code)

For a custom response shape, write your own response inside the handler on the success path and use HTTPStatus directly for the failure path.

Example
// HTTPHandler bridges classification into net/http: any error returned
// by the handler is classified and written as a safe JSON response.
// The body never contains the raw error message.
fetch := HTTPHandler(func(w http.ResponseWriter, r *http.Request) error {
	return NewRejection("battle.not_found", "battle 42 missing").WithHTTPStatus(404)
})

rec := httptest.NewRecorder()
fetch.ServeHTTP(
	rec,
	httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/battles/42", nil),
)
fmt.Println(rec.Code)
fmt.Println(rec.Body.String())
Output:
404
{"code":"battle.not_found","family":"rejection"}

func HTTPStatus added in v0.6.0

func HTTPStatus(err error) int

HTTPStatus returns the recommended HTTP response status code for an error. If the error (or any error in its chain) implements HTTPStatuser with a non-zero status, that status overrides the family-based default.

w.WriteHeader(errorfamily.HTTPStatus(err))

Nil errors classify as Rejection → 400; prefer checking for nil before reaching the HTTP layer.

Example
// HTTPStatus checks the HTTPStatuser interface first (per-error override),
// then falls back to the family's canonical HTTP status.
fmt.Println(HTTPStatus(NewRejection("bad.input", "msg")))
fmt.Println(HTTPStatus(NewConflict("order.duplicate", "msg")))
fmt.Println(HTTPStatus(NewRejection("not.found", "msg").WithHTTPStatus(404)))
Output:
400
409
404

func HandleError

func HandleError(err error) int

HandleError is the CLI boundary handler — the meta service.

Call this exactly once at the top of main():

func main() {
    if err := run(); err != nil {
        os.Exit(errorfamily.HandleError(err))
    }
}

It:

  1. Classifies the error (Family → exit code)
  2. Extracts Code and Context for specific messaging
  3. Optionally runs diagnostic rules
  4. Formats a Wix-quality message for the user
  5. Writes to stderr and returns the exit code
Example
err := NewRejection("file.not_found", "config missing").
	WithContext("path", "/etc/app/config.yaml")
_ = HandleError(err)

func HandleErrorWithConfig

func HandleErrorWithConfig(err error, cfg HandleConfig) int

HandleErrorWithConfig is the configurable version of HandleError.

func HandleErrorWithContext added in v0.3.0

func HandleErrorWithContext(ctx context.Context, err error, cfg HandleConfig) int

HandleErrorWithContext handles an error with caller-provided context for cancellation and diagnostic propagation. This is the preferred entry point when the caller has a context.Context available.

All other HandleError variants delegate to this function.

Example
// HandleErrorWithContext is the canonical entry point — HandleError and
// HandleErrorWithConfig delegate to it. Prefer it whenever a
// context.Context is available.
err := NewRejection("file.not_found", "config missing").
	WithContext("path", "/etc/app/config.yaml")
code := HandleErrorWithContext(context.Background(), err, HandleConfig{
	Output: io.Discard,
})
fmt.Println(code)
Output:
1

func IsRetryable

func IsRetryable(err error) bool

IsRetryable reports whether the error is worth retrying. Uses Classify() and checks if the result is Transient.

Example
fmt.Println(IsRetryable(NewTransient("db.timeout", "query timed out")))
fmt.Println(IsRetryable(NewRejection("bad.input", "invalid input")))
fmt.Println(IsRetryable(errors.New("unknown origin")))
Output:
true
false
true

func LogError added in v0.6.0

func LogError(err error, logger *slog.Logger)

LogError logs an error with structured fields derived from its classification: family, code, retryable, exit_code, and every error-context key (prefixed with "context.").

Severity mapping follows the behavioral family:

  • Transient (retryable) → slog.LevelWarn, since these are expected to self-heal.
  • All other families → slog.LevelError.

A nil error is a no-op. Pass a nil logger to use slog.Default.

logger := slog.Default()
if err := run(); err != nil {
    errorfamily.LogError(err, logger)
}
Example
// LogError maps the behavioral family onto slog severity: Transient
// (expected to self-heal) logs at Warn, everything else at Error.
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
	ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
		if a.Key == slog.TimeKey {
			return slog.Attr{}
		}

		return a
	},
}))
err := NewTransient("db.timeout", "query timed out").
	WithContext("host", "db1")
LogError(err, logger)
Output:
level=WARN msg="[transient:db.timeout] query timed out" family=transient code=db.timeout retryable=true exit_code=75 context.host=db1

func LogErrorContext added in v0.6.0

func LogErrorContext(ctx context.Context, err error, logger *slog.Logger)

LogErrorContext is the context-accepting variant of LogError, for cancellation propagation and trace correlation in instrumented services.

func RegisterClassification

func RegisterClassification(sentinel error, family Family)

RegisterClassification maps a third-party sentinel error to a Family. Thread-safe. Call from init() in external packages:

func init() {
    errorfamily.RegisterClassification(sql.ErrConnDone, errorfamily.Transient)
}

This is for errors you don't own (stdlib, libraries). For your own errors, implement the Classified interface instead.

Delegates to DefaultRegistry. For scoped registration, use Registry.RegisterClassification on a custom Registry.

Example
sentinel := errors.New("connection pool exhausted")
RegisterClassification(sentinel, Transient)

family := Classify(sentinel)
fmt.Println(family)
Output:
transient

func RegisterClassificationType added in v0.8.0

func RegisterClassificationType[T error](
	family Family,
)

RegisterClassificationType registers a type-based classifier that maps any error matching type T to the given Family. It is syntactic sugar over RegisterClassifier for the common case where the classification rule is simply "type T → Family F", without field-level inspection.

errorfamily.RegisterClassificationType[*sqlite.Error](errorfamily.Transient)

For errors that need field-level logic (e.g. different sqlite error codes mapping to different families), use RegisterClassifier with a closure.

For a custom Registry, use RegisterClassificationTypeFor.

Delegates to DefaultRegistry.

Example
// RegisterClassificationType maps any error of type T to a Family.
// Useful for dynamic third-party errors where every instance is fresh.
reg := NewRegistry()
RegisterClassificationTypeFor[*exampleSQLError](reg, Transient)

err := &exampleSQLError{msg: "database is locked"}
fmt.Println(reg.Classify(err))
Output:
transient

func RegisterClassificationTypeFor added in v0.8.0

func RegisterClassificationTypeFor[T error](
	r *Registry,
	family Family,
)

RegisterClassificationTypeFor registers a type-based classifier on a specific Registry. It is the generic counterpart to Registry.RegisterClassifier:

errorfamily.RegisterClassificationTypeFor[*sqlite.Error](myRegistry, errorfamily.Transient)

Go does not permit type parameters on methods, so this is a top-level function rather than a method on Registry.

func RegisterClassifications

func RegisterClassifications(classifications map[error]Family)

RegisterClassifications registers multiple sentinel-to-Family mappings at once. Thread-safe. Call from init() in external packages.

func RegisterClassifier added in v0.6.0

func RegisterClassifier(c Classifier)

RegisterClassifier adds a predicate-based Classifier for dynamic third-party errors that cannot be registered as sentinels (e.g. *sqlite.Error). Thread-safe. Classifiers run after sentinel matching fails, in registration order; the first returning ok=true wins.

Because Go func values are not comparable, individual classifiers cannot be unregistered. For test isolation or scoped handling, construct a NewRegistry and call Registry.RegisterClassifier instead of polluting DefaultRegistry.

Delegates to DefaultRegistry.

func RegisterClassifiers added in v0.6.0

func RegisterClassifiers(cs ...Classifier)

RegisterClassifiers adds multiple predicate-based Classifier funcs at once. Thread-safe. Classifiers run in registration order. See RegisterClassifier.

Delegates to DefaultRegistry.

func RegisterStdlibDefaults added in v0.5.0

func RegisterStdlibDefaults(reg *Registry)

RegisterStdlibDefaults registers classification mappings for common Go standard-library errors onto the given Registry. Pass DefaultRegistry to affect the package-level Classify, or a custom Registry for scoped handling.

Ambiguous cases and the rationale for their assigned Family:

  • context.DeadlineExceeded → Transient (retryable). Usually means an upstream was slow; a fresh attempt may succeed. If the deadline was set too short by the caller, the fix is at the call site, but the error itself is still transient in nature.
  • context.Canceled → Rejection (not retryable). The caller abandoned the operation; retrying without a new request is wrong.
  • sql.ErrNoRows / os.ErrNotExist → Rejection. A missing resource is the caller's concern (wrong key / path), not a system fault.
  • sql.ErrConnDone → Transient. The connection pool closed a connection; a retry on a fresh connection typically succeeds.
  • os.ErrPermission → Rejection. Permission is a caller/environment state, not a transient failure.

This is advisory: applications whose semantics differ should register their own mappings after calling this.

func RegisterTemplate

func RegisterTemplate(code string, tmpl MessageTemplate)

RegisterTemplate adds a MessageTemplate for a specific error code. Thread-safe. Overrides any existing template for the same code.

Delegates to DefaultRegistry. For scoped registration, use Registry.RegisterTemplate on a custom Registry.

func UnregisterClassification added in v0.3.0

func UnregisterClassification(sentinel error)

UnregisterClassification removes a previously registered sentinel mapping. Thread-safe. No-op if the sentinel has no registered classification.

func UnregisterTemplate added in v0.3.0

func UnregisterTemplate(code string)

UnregisterTemplate removes a previously registered template. Thread-safe. No-op if the code has no registered template.

Types

type Audience

type Audience int

Audience describes who should be notified about this error.

const (
	// AudienceUser indicates the error is the requester's concern.
	AudienceUser Audience = iota
	// AudienceOps indicates the error needs operational intervention.
	AudienceOps
	// AudienceAll indicates all parties should be notified.
	AudienceAll
)

func ParseAudience added in v0.4.0

func ParseAudience(s string) Audience

ParseAudience parses an audience string, case-insensitive. Returns AudienceUser for unrecognized values (safest default).

func (Audience) IsValid added in v0.4.0

func (a Audience) IsValid() bool

IsValid reports whether the Audience value is one of the three defined constants.

func (Audience) MarshalText added in v0.4.0

func (a Audience) MarshalText() ([]byte, error)

MarshalText implements encoding.TextMarshaler for YAML/JSON config.

func (Audience) String added in v0.1.2

func (a Audience) String() string

String returns the lowercase name of the Audience (e.g. "user", "ops").

func (*Audience) UnmarshalText added in v0.4.0

func (a *Audience) UnmarshalText(
	text []byte,
) error

UnmarshalText implements encoding.TextUnmarshaler for YAML/JSON config. Unknown values are parsed as AudienceUser (safest default).

type Classified

type Classified interface {
	error
	ErrorFamily() Family
}

Classified provides behavioral classification. Required for: retry decisions, exit codes, circuit breakers, tone selection.

type Classifier added in v0.6.0

type Classifier func(error) (Family, bool)

Classifier is a predicate-based error classifier for dynamic third-party errors that cannot be registered as sentinels — each error is a fresh instance not matchable by errors.Is identity (e.g. *sqlite.Error, *pgconn.PgError).

Register one via RegisterClassifier or Registry.RegisterClassifier. During Classify, after the Classified interface, Retryable inference, and registered sentinels all miss, each registered Classifier runs in registration order; the first to return ok=true wins. Classifiers only run on otherwise-unclassified errors, so the hot path (errors that already declare a family) is untouched.

Example — classifying modernc.org/sqlite dynamic errors:

errorfamily.RegisterClassifier(func(err error) (errorfamily.Family, bool) {
    var sqliteErr *sqlite.Error
    if errors.As(err, &sqliteErr) {
        switch sqliteErr.Code() {
        case 5, 6: return errorfamily.Transient, true // BUSY, LOCKED
        case 19:   return errorfamily.Conflict, true  // CONSTRAINT
        }
    }
    return errorfamily.Transient, false
})

type Coded

type Coded interface {
	error
	ErrorCode() string
}

Coded provides machine-readable identity. Required for: metric labels, log fields, message templates, exit code mapping.

type Contextual

type Contextual interface {
	error
	ErrorContext() map[string]string
}

Contextual provides factual details about the error. Required for: structured logging, specific error messages, debug context, and triggering diagnostic rules.

type DiagnosticFinding

type DiagnosticFinding struct {
	// RuleName identifies which diagnostic rule produced this finding.
	RuleName string
	// Status is the check outcome: "healthy", "degraded", "failed", or "unknown".
	Status string
	// Summary is a human-readable explanation of the finding.
	Summary string
	// SuggestedFix is an actionable resolution suggested by the rule.
	SuggestedFix string
	// Confidence is 0.0–1.0 indicating how likely this explains the error.
	Confidence float64
}

DiagnosticFinding is a minimal result type for the CLI boundary. Avoids importing diagnose while preserving type safety.

type DiagnosticFunc

type DiagnosticFunc func(ctx context.Context, err error) []DiagnosticFinding

DiagnosticFunc runs diagnostics for an error and returns results. The diagnose.Runner satisfies this via its Run method.

type Error

type Error struct {
	// contains filtered or unexported fields
}

Error is the reference implementation of a classified, structured error.

This is a convenience type, not the contract. The public protocol is the Coded/Classified/Contextual/Retryable interfaces — your own domain error types implement only those and need not embed or use this struct.

Projects with simple error needs can use this directly. Projects with domain-specific needs (e.g., FindingError with Position/File) can build their own struct and implement the Coded/Classified/Contextual interfaces.

Implements: error, Coded, Classified, Contextual, Retryable, ExitCoder, fmt.Formatter.

func New

func New(family Family, code, message string) *Error

New creates a new Error with the given family, code, and message.

func NewConflict

func NewConflict(code, message string) *Error

NewConflict creates a Conflict error (version mismatch, duplicate).

Example
err := NewConflict("game.already_finished", "move after game over").
	WithContext("game_id", "g-42")
fmt.Println(err, Classify(err).HTTPStatus(), Classify(err).IsRetryable())
Output:
[conflict:game.already_finished] move after game over 409 false

func NewCorruption

func NewCorruption(code, message string) *Error

NewCorruption creates a Corruption error (data damaged, not self-healable).

Example
err := NewCorruption("state.checksum_mismatch", "stored game state failed checksum")
fmt.Println(err, Classify(err).HTTPStatus(), Classify(err).Severity())
Output:
[corruption:state.checksum_mismatch] stored game state failed checksum 500 6

func NewInfrastructure

func NewInfrastructure(code, message string) *Error

NewInfrastructure creates an Infrastructure error (system cannot serve).

Example
err := NewInfrastructure("fs.readonly", "filesystem mounted read-only").
	WithContext("path", "/var/lib/app")
fmt.Println(err, Classify(err).HTTPStatus(), Classify(err).IsRetryable())
Output:
[infrastructure:fs.readonly] filesystem mounted read-only 503 false

func NewOrchestration added in v0.10.0

func NewOrchestration(code, message string) *Error

NewOrchestration creates an Orchestration error (internal coordination failure). Use this for bugs, misconfigurations, and internal logic failures that are neither I/O failures nor user input errors — e.g. "render status tree", "build CLI command", "wire dependencies". Not retryable. No user-facing fix.

Example
err := NewOrchestration(
	"workflow.double_transition",
	"state machine applied the same transition twice",
)
fmt.Println(err, Classify(err).HTTPStatus(), Classify(err).ExitCode())
Output:
[orchestration:workflow.double_transition] state machine applied the same transition twice 500 70

func NewRejection

func NewRejection(code, message string) *Error

NewRejection creates a Rejection error (bad input, unauthorized, not found).

func NewTransient

func NewTransient(code, message string) *Error

NewTransient creates a Transient error (temporary failure, retryable).

Example
err := NewTransient("db.timeout", "query took too long").
	WithContext("host", "localhost").
	WithContext("port", "5432")
fmt.Println(err)
Output:
[transient:db.timeout] query took too long

func Newf

func Newf(family Family, code, format string, args ...any) *Error

Newf creates a new Error with a formatted message.

func Wrap

func Wrap(err error, family Family, code, message string) *Error

Wrap wraps an existing error with family, code, and message. Returns nil if err is nil.

Example
// Wrap attaches a behavioral family to an error from a lower layer
// (stdlib, driver, another library) at the layer that knows the domain.
_, openErr := os.Open("/etc/app/config.yaml")

err := Wrap(openErr, Infrastructure, "config.unreadable", "cannot read config")

fmt.Println(err)
fmt.Println(Classify(err))
Output:
[infrastructure:config.unreadable] cannot read config: open /etc/app/config.yaml: no such file or directory
infrastructure

func WrapConflict

func WrapConflict(err error, code, message string) *Error

WrapConflict wraps an error as Conflict.

func WrapConflictf added in v0.6.0

func WrapConflictf(err error, code, format string, args ...any) *Error

WrapConflictf wraps an error as Conflict with a formatted message.

func WrapCorruption

func WrapCorruption(err error, code, message string) *Error

WrapCorruption wraps an error as Corruption.

func WrapCorruptionf added in v0.6.0

func WrapCorruptionf(err error, code, format string, args ...any) *Error

WrapCorruptionf wraps an error as Corruption with a formatted message.

func WrapInfrastructure

func WrapInfrastructure(err error, code, message string) *Error

WrapInfrastructure wraps an error as Infrastructure.

func WrapInfrastructuref added in v0.6.0

func WrapInfrastructuref(err error, code, format string, args ...any) *Error

WrapInfrastructuref wraps an error as Infrastructure with a formatted message.

func WrapOnce added in v0.8.0

func WrapOnce(err error, family Family, code, message string) *Error

WrapOnce wraps an error only if it is not already a *Error. Use this at API boundaries where errors may have been classified by an inner layer. Prevents double-wrapping chains like:

[transient:db.timeout] outer: [transient:db.timeout] inner: cause

If err is already a *Error, it is returned unchanged. If err is nil, returns nil (same nil-safety as Wrap).

Example
// Inner layer already classified the error.
inner := NewTransient("db.timeout", "database timed out")

// API boundary: WrapOnce returns the existing *Error unchanged
// instead of creating a double-wrapped chain.
err := WrapOnce(inner, Infrastructure, "api.error", "API failed")

fmt.Println(err == inner)
Output:
true

func WrapOncef added in v0.8.0

func WrapOncef(err error, family Family, code, format string, args ...any) *Error

WrapOncef is the formatted-message variant of WrapOnce. Wraps an error only if it is not already a *Error, using a printf-style format string for the message. Returns nil if err is nil. Returns the existing *Error unchanged if already classified.

func WrapOrchestration added in v0.10.0

func WrapOrchestration(err error, code, message string) *Error

WrapOrchestration wraps an error as Orchestration (internal coordination failure).

func WrapOrchestrationf added in v0.10.0

func WrapOrchestrationf(err error, code, format string, args ...any) *Error

WrapOrchestrationf wraps an error as Orchestration with a formatted message.

func WrapRejection

func WrapRejection(err error, code, message string) *Error

WrapRejection wraps an error as Rejection.

Example
orig := errors.New("file does not exist")
err := WrapRejection(orig, "config.invalid", "bad configuration")
fmt.Println(err)
Output:
[rejection:config.invalid] bad configuration: file does not exist

func WrapRejectionf added in v0.6.0

func WrapRejectionf(err error, code, format string, args ...any) *Error

WrapRejectionf wraps an error as Rejection with a formatted message.

func WrapTransient

func WrapTransient(err error, code, message string) *Error

WrapTransient wraps an error as Transient (retryable).

func WrapTransientf added in v0.6.0

func WrapTransientf(err error, code, format string, args ...any) *Error

WrapTransientf wraps an error as Transient (retryable) with a formatted message.

func Wrapf

func Wrapf(err error, family Family, code, format string, args ...any) *Error

Wrapf wraps an error with a formatted message. Returns nil if err is nil.

func (*Error) Cause

func (e *Error) Cause() error

Cause returns the underlying error in the chain.

func (*Error) Code

func (e *Error) Code() string

Code returns the machine-readable error code.

This is a direct accessor — a convenience sibling of Error.Family and Error.Message for use when you already hold a concrete *Error and want the field without an interface assertion. It returns the SAME value as Error.ErrorCode; the two exist because ErrorCode is the Coded interface contract (used via errors.AsType / Code(err)), while Code is the ergonomic accessor on the concrete type. Neither is deprecated.

func (*Error) ContextValue

func (e *Error) ContextValue(key string) string

ContextValue returns a specific context value, or empty string if not present.

func (*Error) Error

func (e *Error) Error() string

Error implements the error interface. Returns a compact format: [family:code] message. The cause string is extracted via [safeCauseString] to guard against panics from misbehaving wrapped error types.

func (*Error) ErrorCode

func (e *Error) ErrorCode() string

ErrorCode returns the machine-readable error code.

This is the canonical accessor: it satisfies the Coded interface, which is the public contract for code extraction (used by Code, HandleError, metrics, and log fields). Prefer this over Error.Code when you need the interface behavior. See Error.Code for the difference.

func (*Error) ErrorContext

func (e *Error) ErrorContext() map[string]string

ErrorContext returns a copy of the error's factual context.

func (*Error) ErrorFamily

func (e *Error) ErrorFamily() Family

ErrorFamily returns the behavioral classification.

func (*Error) ExitCode added in v0.8.0

func (e *Error) ExitCode() int

ExitCode returns the custom process exit code if one was set via [WithExitCode]. Returns 0 when no custom code is set, signaling callers to fall back to the family-based default from Family.ExitCode.

This satisfies the ExitCoder interface so that ExitCode (the package-level function) can discover per-error overrides.

func (*Error) Family

func (e *Error) Family() Family

Family returns the error's behavioral classification. Convenience accessor for direct use without interface assertion.

func (*Error) Format

func (e *Error) Format(f fmt.State, verb rune)

Format implements fmt.Formatter.

%v    → [family:code] message
%+v   → verbose multi-line with context and cause chain
%s    → message only (no classification prefix)

func (*Error) HTTPStatus added in v0.8.0

func (e *Error) HTTPStatus() int

HTTPStatus returns the custom HTTP response status code if one was set via [WithHTTPStatus]. Returns 0 when no custom status is set, signaling callers to fall back to the family-based default from Family.HTTPStatus.

This satisfies the HTTPStatuser interface so that HTTPStatus (the package-level function) can discover per-error overrides.

func (*Error) HasContext

func (e *Error) HasContext(key string) bool

HasContext reports whether the error has a specific context key.

func (*Error) Is

func (e *Error) Is(target error) bool

Is supports errors.Is by matching error code and family. Two errors match if they have the same code AND family.

func (*Error) IsRetryable

func (e *Error) IsRetryable() bool

IsRetryable reports whether this error is worth retrying.

func (*Error) JSON added in v0.5.0

func (e *Error) JSON() ([]byte, error)

JSON returns a canonical JSON encoding of the error for API boundaries. The shape is stable: {family, code, message, context, retryable, timestamp}. Use this for HTTP/REST error responses where a structured body is preferable to the [transient:code] message format of Error().

Example
err := NewTransient("db.timeout", "query timed out").
	WithContext("host", "db1").
	WithTimestamp(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
data, _ := err.JSON()
fmt.Println(string(data))
Output:
{"family":"transient","code":"db.timeout","message":"query timed out","context":{"host":"db1"},"retryable":true,"timestamp":"2026-01-01T00:00:00Z"}

func (*Error) Message

func (e *Error) Message() string

Message returns the human-readable technical message.

func (*Error) Summary

func (e *Error) Summary() string

Summary returns a one-line human-readable summary suitable for logs and CLI output. Format: "code: message" without family prefix. The cause string is extracted via [safeCauseString] to guard against panics.

func (*Error) Timestamp

func (e *Error) Timestamp() time.Time

Timestamp returns when this error was created.

func (*Error) Unwrap

func (e *Error) Unwrap() error

Unwrap returns the underlying cause for error chain traversal.

func (*Error) WithCause

func (e *Error) WithCause(cause error) *Error

WithCause sets the underlying cause and returns a new error for chaining.

func (*Error) WithContext

func (e *Error) WithContext(key, value string) *Error

WithContext adds a key-value pair to the error's context. Returns a new Error, leaving the original unchanged — safe for shared/sentinel errors.

func (*Error) WithContextAny added in v0.8.0

func (e *Error) WithContextAny(key string, value any) *Error

WithContextAny adds a key-value pair to the error's context where the value can be of any type. The value is converted to string via a type switch for common types (string, int, int64, uint, uint64, float64, bool) and falls back to fmt.Sprint for everything else. Returns a new Error, leaving the original unchanged — safe for shared/sentinel errors.

Example
// Add context values of any type — they are converted to strings.
err := NewRejection("validation.failed", "invalid input").
	WithContextAny("count", 3).
	WithContextAny("enabled", true)

fmt.Println(err.ContextValue("count"))
fmt.Println(err.ContextValue("enabled"))
Output:
3
true

func (*Error) WithContextMap added in v0.5.0

func (e *Error) WithContextMap(ctx map[string]string) *Error

WithContextMap merges a map of key-value pairs into the error's context. Returns a new Error, leaving the original unchanged. Nil or empty input returns a clone with no added context.

func (*Error) WithContextf added in v0.5.0

func (e *Error) WithContextf(key, format string, args ...any) *Error

WithContextf adds a formatted key-value pair to the error's context. The value is produced by fmt.Sprintf(format, args...). Returns a new Error, leaving the original unchanged.

func (*Error) WithExitCode added in v0.8.0

func (e *Error) WithExitCode(code int) *Error

WithExitCode sets a custom process exit code that overrides the family-based default from Family.ExitCode. A value of 0 means "not set" — callers should fall back to the family's canonical exit code.

Note: os.Exit wraps the code to the range 0-255 on POSIX (e.g., -1 becomes 255). Use values in the 1-125 range for maximum portability (126+ have special meaning in some shells).

Returns a new Error, leaving the original unchanged — safe for shared/sentinel errors.

Example
// Override the family-based exit code for a specific error.
// Useful when a transient error should exit with a code that
// differs from the BSD sysexits default (75).
err := NewTransient("rate.limited", "too many requests").WithExitCode(88)
fmt.Println(err.ExitCode())
Output:
88

func (*Error) WithHTTPStatus added in v0.8.0

func (e *Error) WithHTTPStatus(status int) *Error

WithHTTPStatus sets a custom HTTP response status code that overrides the family-based default from Family.HTTPStatus. A value of 0 means "not set" — callers should fall back to the family's canonical HTTP status.

Use this for errors whose family default is misleading at the HTTP layer:

notFound := NewRejection("battle.not_found", "battle not found").
    WithHTTPStatus(http.StatusNotFound) // 404 instead of family default 400

Returns a new Error, leaving the original unchanged — safe for shared/sentinel errors.

Example
// Override the family-based HTTP status for a specific error.
// The family default for Rejection is 400 (Bad Request), but a
// "not found" error is more precisely a 404.
err := NewRejection("battle.not_found", "battle not found").WithHTTPStatus(404)
fmt.Println(err.HTTPStatus())
Output:
404

func (*Error) WithTimestamp added in v0.3.0

func (e *Error) WithTimestamp(ts time.Time) *Error

WithTimestamp sets the error timestamp and returns a new error for chaining. Useful for testing and deterministic construction.

type ExitCoder added in v0.8.0

type ExitCoder interface {
	error
	ExitCode() int
}

ExitCoder errors carry a custom process exit code that overrides the family-based default from Family.ExitCode. When ExitCode encounters an error implementing this interface with a non-zero code, that code wins.

This allows individual errors to deviate from their family's canonical exit code without changing the family classification itself.

type Family

type Family int

Family classifies an error's behavioral profile for automated handling.

One concept serving three audiences:

  • Retry loops: "Should I try again?" (Transient = yes)
  • Exit codes: "Which exit code for the shell?" (maps to BSD sysexits.h)
  • Presentation: "Whose fault is it?" (determines tone and framing in user messages)
const (
	// Rejection indicates bad input, unauthorized access, or resource not found.
	// No state changed. Not retryable. User's fault. Tone: helpful, instructional.
	Rejection Family = iota

	// Conflict indicates version mismatch, duplicate creation, or state machine violation.
	// No state changed for requester. Not retryable. User needs to resolve. Tone: explanatory.
	Conflict

	// Transient indicates a temporary infrastructure failure.
	// State unknown. Retryable with backoff. System's fault. Tone: reassuring.
	Transient

	// Corruption indicates the source of truth is damaged (unparseable payload, schema break).
	// Not self-healable. Not retryable. Serious. Tone: urgent, escalate to ops.
	Corruption

	// Infrastructure indicates the system cannot serve (closed, nil deps, startup failure).
	// Not retryable. System's fault. Tone: apologetic.
	Infrastructure

	// Orchestration indicates an internal coordination failure — the program's own
	// logic failed to complete an operation (rendering output, building a CLI command,
	// wiring dependencies, writing config). Not an I/O failure, not user input, not
	// a data-integrity break: a bug or misconfiguration in the program itself.
	// Not retryable. System's fault. No user-facing fix (report the bug).
	Orchestration
)

func Classify

func Classify(err error) Family

Classify returns the Family of any error by checking multiple sources:

  1. Multi-error support (errors.Join) — first non-Transient wins
  2. Classified interface — the error itself declares its family
  3. Retryable interface — infer from retryability if no family
  4. Registered sentinels — known third-party errors mapped via RegisterClassification
  5. Registered classifiers — predicate-based matching for dynamic errors (RegisterClassifier)
  6. Default — Transient (fail-open for retry)

Returns Rejection for nil errors.

This function delegates to DefaultRegistry. For scoped or test-isolated classification, construct a Registry via NewRegistry and use its Classify method.

Example
err := NewRejection("file.not_found", "config missing")
family := Classify(err)
fmt.Println(family.IsRetryable(), family.ExitCode())
Output:
false 1

func ParseFamily

func ParseFamily(s string) Family

ParseFamily parses a family string, case-insensitive. Returns Transient for unrecognized values (fail-open for retry).

Example
fmt.Println(ParseFamily("transient"))
fmt.Println(ParseFamily("UNKNOWN"))
Output:
transient
transient

func (Family) Audience

func (f Family) Audience() Audience

Audience returns who should be notified about errors of this family.

func (Family) DefaultFix added in v0.1.2

func (f Family) DefaultFix() string

DefaultFix returns the default fix suggestion for this family.

func (Family) DefaultMessage

func (f Family) DefaultMessage() string

DefaultMessage returns the default human-readable message for this family.

func (Family) DefaultWhy added in v0.1.2

func (f Family) DefaultWhy() string

DefaultWhy returns the default "why" explanation for this family.

func (Family) ExitCode

func (f Family) ExitCode() int

ExitCode returns the BSD sysexits.h compatible exit code for this family.

func (Family) HTTPStatus added in v0.5.0

func (f Family) HTTPStatus() int

HTTPStatus returns the recommended HTTP response status code for this family. Use at HTTP/REST boundaries to translate a classified error into a response code:

Rejection → 400, Conflict → 409, Transient → 503,
Corruption → 500, Infrastructure → 503, Orchestration → 500.

Invalid families return 500 (Internal Server Error).

Rationale

The mapping reflects "whose fault is it, and what can the client do?":

  • Rejection → 400 (Bad Request): the client sent bad input, lacked authorization, or asked for a missing resource. The client can fix this.
  • Conflict → 409 (Conflict): the request conflicts with current state (duplicate, version mismatch). The client must refresh and reconcile.
  • Transient → 503 (Service Unavailable): a temporary, retryable failure. The client SHOULD retry, ideally with backoff.
  • Corruption → 500 (Internal Server Error): stored data is damaged — a data-integrity break that is the server's problem, not the client's. The client did nothing wrong and cannot fix it by retrying. (Earlier revisions mapped this to 422; 500 is correct because 422 implies the CLIENT submitted unprocessable data, which is not the case here.)
  • Infrastructure → 503 (Service Unavailable): the system cannot serve the request right now (closed, misconfigured, starting up). Retryable in principle, though possibly requiring operator action.
  • Orchestration → 500 (Internal Server Error): an internal coordination failure (bug, misconfiguration). Not retryable. The client did nothing wrong and cannot fix it by retrying.

Corruption and Infrastructure are distinguished by severity and audience (see Family.Severity and [Family.DefaultAudience]), not by HTTP status.

Example
// Translate a family into an HTTP status code at REST boundaries.
err := NewConflict("order.duplicate", "order already exists")
fmt.Println(Classify(err).HTTPStatus())
Output:
409

func (Family) IsRetryable

func (f Family) IsRetryable() bool

IsRetryable reports whether operations that encounter this error should be retried.

func (Family) IsValid

func (f Family) IsValid() bool

IsValid reports whether the Family value is one of the six defined constants.

func (Family) MarshalText added in v0.4.0

func (f Family) MarshalText() ([]byte, error)

MarshalText implements encoding.TextMarshaler for YAML/JSON config.

Example
f := Transient
text, _ := f.MarshalText()
fmt.Println(string(text))
Output:
transient

func (Family) RetryPolicy added in v0.5.0

func (f Family) RetryPolicy() RetryPolicy

RetryPolicy returns sensible retry defaults for this family. Only Transient is retryable; all other families return a single-attempt policy.

Example
// RetryPolicy exposes advisory defaults — the library does not run the
// retry loop. Only Transient gets more than a single attempt.
fmt.Println(Transient.RetryPolicy().MaxAttempts)
fmt.Println(Rejection.RetryPolicy().MaxAttempts)
Output:
3
1

func (Family) Severity added in v0.5.0

func (f Family) Severity() int

Severity returns a total order across families for multi-error classification. Higher = worse. Used by Classify to pick the worst sub-error of an errors.Join result deterministically, independent of argument order.

Transient(1) < Rejection(2) < Conflict(3) < Infrastructure(4) < Orchestration(5) < Corruption(6)

This preserves the fail-closed retry guarantee: if ANY sub-error is non-Transient (severity > 1), the joined error is non-Transient.

func (Family) String

func (f Family) String() string

String returns the lowercase name of the Family (e.g. "transient", "rejection").

func (Family) Tone

func (f Family) Tone() Tone

Tone returns the presentation tone hint for this family.

func (*Family) UnmarshalText added in v0.4.0

func (f *Family) UnmarshalText(
	text []byte,
) error

UnmarshalText implements encoding.TextUnmarshaler for YAML/JSON config. Unknown values are parsed as Transient (fail-open).

Example
var f Family

_ = f.UnmarshalText([]byte("rejection"))
fmt.Println(f)
Output:
rejection

type HTTPStatuser added in v0.8.0

type HTTPStatuser interface {
	error
	HTTPStatus() int
}

HTTPStatuser errors carry a custom HTTP response status code that overrides the family-based default from Family.HTTPStatus. When HTTPStatus encounters an error implementing this interface with a non-zero status, that status wins.

This allows individual errors to deviate from their family's canonical HTTP status (e.g. a Rejection error that should be 404 Not Found instead of the family default 400 Bad Request) without changing the classification.

type HandleConfig

type HandleConfig struct {
	// Output is where human-readable messages are written. Defaults to os.Stderr.
	Output io.Writer

	// Registry provides classification sentinels and message templates.
	// If nil, DefaultRegistry is used. Set this for test isolation or
	// scoped error handling within a single binary.
	Registry *Registry

	// TemplateOverride overrides the default message template for a specific error code.
	// Takes precedence over registered templates.
	// map[errorCode]MessageTemplate
	TemplateOverride map[string]MessageTemplate

	// DiagnosticFunc runs diagnostics for the error. If nil, no diagnostics run.
	// When set, diagnostics run automatically — no separate enable flag needed.
	DiagnosticFunc DiagnosticFunc

	// OnDiagnosed is called after diagnostics complete, before exit.
	// Receives the error and diagnostic findings. Useful for logging/metrics.
	OnDiagnosed func(err error, findings []DiagnosticFinding)

	// Logger, when set, receives a structured log entry (family, code,
	// retryable, exit_code, context.*) in the same call as human output —
	// classifying once, logging once. Nil (default) skips structured logging,
	// preserving the original behavior.
	//
	// This is the recommended hook for systemd/journald observability:
	// every HandleError call emits a self-contained structured record without
	// a separate LogError call.
	Logger *slog.Logger
}

HandleConfig controls how HandleError processes an error at the CLI boundary.

type HandleResult

type HandleResult struct {
	// ExitCode is the process exit code derived from the error's Family.
	ExitCode int
	// Message is the human-readable error message.
	Message string
	// SuggestedFix is an actionable suggestion for resolving the error.
	SuggestedFix string
}

HandleResult contains the full output of handling an error at the CLI boundary.

func HandleErrorDetailed

func HandleErrorDetailed(err error) *HandleResult

HandleErrorDetailed returns a structured result without writing output. Useful for HTTP handlers, gRPC interceptors, and programmatic consumers.

Uses the same template resolution chain as HandleError: registered templates, built-in defaults, and family fallbacks.

Example
err := NewRejection("file.not_found", "config missing").
	WithContext("path", "/etc/app/config.yaml")
result := HandleErrorDetailed(err)
fmt.Println("exit:", result.ExitCode)
fmt.Println("fix:", result.SuggestedFix)
Output:
exit: 1
fix: Check that the path and resource name are correct.

func HandleErrorDetailedWithConfig added in v0.3.0

func HandleErrorDetailedWithConfig(err error, cfg HandleConfig) *HandleResult

HandleErrorDetailedWithConfig returns a structured result with template overrides.

type HandlerFunc added in v0.6.0

type HandlerFunc func(http.ResponseWriter, *http.Request) error

HandlerFunc is an HTTP handler that returns an error. When the returned error is nil, the request is considered fully handled. A non-nil error is classified and translated into an HTTP error response by HTTPHandler.

Use this with HTTPHandler to bridge go-error-family classification into the net/http stack without per-handler boilerplate.

type MessageTemplate

type MessageTemplate struct {
	// What describes what happened. Supports {key} placeholders from error context.
	What string
	// Why explains why it happened. Supports {key} placeholders.
	Why string
	// Fix suggests how to resolve the error. Supports {key} placeholders.
	Fix string
	// WayOut provides an escape hatch or alternative action. Supports {key} placeholders.
	WayOut string
}

MessageTemplate defines the Wix-style presentation for an error code. Based on the Wix UX framework: What / Why / Fix / WayOut.

func TemplateForCode added in v0.6.0

func TemplateForCode(code string) (MessageTemplate, bool)

TemplateForCode resolves a MessageTemplate for an error code, checking registered templates on DefaultRegistry first, then built-in defaults. Returns (zero, false) when no template exists for the code.

Delegates to DefaultRegistry. For scoped lookups, use Registry.TemplateForCode on a custom Registry.

type Registry added in v0.5.0

type Registry struct {
	// contains filtered or unexported fields
}

Registry holds classification sentinels and message templates. It is safe for concurrent use. The zero value is not usable — use NewRegistry.

A Registry solves two problems that package-level globals cannot:

  • Test isolation: each test can construct its own Registry without polluting global state or needing t.Cleanup(Unregister…) boilerplate.
  • Scoped overrides: a service can register sentinels or templates for its own error handling without affecting other parts of the same binary.

The package-level DefaultRegistry is used by the convenience functions (Classify, RegisterClassification, RegisterTemplate, etc.) and by the HandleError family when no custom Registry is provided in HandleConfig.

Sentinels are stored behind an atomic.Pointer to an immutable snapshot: the hot read path (every Classify) is lock-free and allocation-free, while rare writes serialize under the write lock and publish a new snapshot via copy-on-write.

func NewRegistry added in v0.5.0

func NewRegistry() *Registry

NewRegistry creates an empty Registry ready for use.

Example
// A custom Registry enables test isolation and scoped handling without
// touching the package-level DefaultRegistry.
reg := NewRegistry()
reg.RegisterClassification(errors.New("sentinel"), Transient)

fmt.Println(reg.Classify(errors.New("sentinel")))
Output:
transient

func (*Registry) Classify added in v0.5.0

func (r *Registry) Classify(err error) Family

Classify returns the Family of any error using this registry's sentinel mappings.

Checks in order, first match wins:

  1. Multi-error (errors.Join) — first non-Transient sub-error wins
  2. Classified interface — the error itself declares its family
  3. Retryable interface — infer from retryability
  4. Registered sentinels in this registry
  5. Registered classifiers (predicate-based, for dynamic third-party errors)
  6. Default — Transient (fail-open for retry)

Returns Rejection for nil errors.

func (*Registry) Clone added in v0.5.0

func (r *Registry) Clone() *Registry

Clone returns a new Registry with a deep copy of this Registry's sentinels and templates. Mutations to the clone do not affect the original and vice versa. Enables inherit-and-extend patterns: start from DefaultRegistry, clone it, and register scope-specific overrides without touching the global.

func (*Registry) RegisterClassification added in v0.5.0

func (r *Registry) RegisterClassification(sentinel error, family Family)

RegisterClassification maps a third-party sentinel error to a Family. Thread-safe. Call from init() in external packages:

func init() {
    errorfamily.DefaultRegistry.RegisterClassification(sql.ErrConnDone, errorfamily.Transient)
}

This is for errors you don't own (stdlib, libraries). For your own errors, implement the Classified interface instead.

func (*Registry) RegisterClassifications added in v0.5.0

func (r *Registry) RegisterClassifications(classifications map[error]Family)

RegisterClassifications registers multiple sentinel-to-Family mappings at once. Thread-safe.

func (*Registry) RegisterClassifier added in v0.6.0

func (r *Registry) RegisterClassifier(c Classifier)

RegisterClassifier adds a predicate-based Classifier for dynamic third-party errors that cannot be registered as sentinels (e.g. *sqlite.Error). Thread-safe. Classifiers run after sentinel matching fails, in registration order; the first returning ok=true wins.

Because Go func values are not comparable, individual classifiers cannot be unregistered. For test isolation or scoped handling, construct a NewRegistry rather than polluting DefaultRegistry.

func (*Registry) RegisterClassifiers added in v0.6.0

func (r *Registry) RegisterClassifiers(classifiers ...Classifier)

RegisterClassifiers adds multiple predicate-based Classifier funcs at once. Thread-safe. Classifiers run in registration order.

func (*Registry) RegisterTemplate added in v0.5.0

func (r *Registry) RegisterTemplate(code string, tmpl MessageTemplate)

RegisterTemplate adds a MessageTemplate for a specific error code. Thread-safe. Overrides any existing template for the same code.

func (*Registry) RegisterTemplates added in v0.5.0

func (r *Registry) RegisterTemplates(templates map[string]MessageTemplate)

RegisterTemplates registers multiple code-to-template mappings at once. Thread-safe. Keys are lower-cased for case-insensitive lookup. Parity with Registry.RegisterClassifications for the sentinel side.

func (*Registry) TemplateForCode added in v0.6.0

func (r *Registry) TemplateForCode(code string) (MessageTemplate, bool)

TemplateForCode resolves a MessageTemplate for an error code using this registry, checking registered templates first, then built-in defaults. Returns (zero, false) when no template exists for the code.

This is the public counterpart to the internal resolution used by HandleError: it lets HTTP/REST consumers look up a registered template without reimplementing the lookup or wiring the full CLI pipeline.

func (*Registry) UnregisterClassification added in v0.5.0

func (r *Registry) UnregisterClassification(sentinel error)

UnregisterClassification removes a previously registered sentinel mapping. Thread-safe. No-op if the sentinel has no registered classification.

func (*Registry) UnregisterTemplate added in v0.5.0

func (r *Registry) UnregisterTemplate(code string)

UnregisterTemplate removes a previously registered template. Thread-safe. No-op if the code has no registered template.

type RetryPolicy added in v0.5.0

type RetryPolicy struct {
	MaxAttempts int
	MinDelay    time.Duration
	MaxDelay    time.Duration
}

RetryPolicy holds retry parameters derived from a Family. It is advisory: the library does not implement the retry loop — consumers (or retry libraries like failsafe-go) use these as sensible starting defaults.

MaxAttempts is the total number of attempts including the first. Non-retryable families return MaxAttempts=1 (execute once, never retry).

type Retryable

type Retryable interface {
	error
	IsRetryable() bool
}

Retryable errors explicitly declare whether they should be retried. Overrides Family-based retry decisions when present.

type Tone

type Tone string

Tone is a presentation tone hint for error messages. Used by the presentation layer to choose appropriate language.

const (
	// ToneInstructional guides the user on how to fix their input.
	ToneInstructional Tone = "instructional"
	// ToneExplanatory explains what happened and why.
	ToneExplanatory Tone = "explanatory"
	// ToneReassuring reassures the user that the issue is temporary.
	ToneReassuring Tone = "reassuring"
	// ToneUrgent signals that immediate action is required.
	ToneUrgent Tone = "urgent"
	// ToneApologetic indicates the system is at fault.
	ToneApologetic Tone = "apologetic"
)

Directories

Path Synopsis
agent module
bridge module
diagnose module
Package errorfamilytest provides assertion helpers for testing code that returns go-error-family classified errors.
Package errorfamilytest provides assertion helpers for testing code that returns go-error-family classified errors.
examples module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL