errx

package module
v0.0.0-...-cdcd6bd Latest Latest
Warning

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

Go to latest
Published: Oct 13, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

README

errx — the structured error library for Go

Go Reference Go Report Card CI codecov tag license Go PRs welcome Conventional Commits

errx is a complete, production-grade error handling library for Go (Golang). One coherent error value carries a stable machine identity, a separate operator and end-user message, typed and redactable structured fields, retry and severity metadata, a lazily-captured stack plus a return-trace, suppressed errors, a deterministic fingerprint, and compiler-grade diagnostics — while staying a drop-in over the standard library errors.Is / errors.As / errors.Join.

It is the error package that replaces the usual pile of five: pkg/errors for stacks, go-multierror for aggregation, a hand-rolled struct for codes, a bespoke HTTP/gRPC mapper, and per-vendor Sentry/OpenTelemetry glue. The core has zero third-party dependencies; every observability and transport integration (Sentry, OpenTelemetry, RFC 9457 problem+json, gRPC, Connect, GraphQL, Prometheus) is a separate, opt-in contrib/* module that consumes one neutral Report — with no init() magic.

err := errx.New("db: deadlock on user_orders").
    WithDomain("billing").WithCode("TX_RETRY").
    WithPublic("Something went wrong, please retry").
    WithRetryable(0).
    WithSafe("shard", "eu-1")          // safe field — survives to logs/wire
    // .With("token", secret)          // unsafe field — auto-redacted at any boundary

httpx.Write(w, r, err)                  // → 503 application/problem+json
slog.Error("request failed", "err", err) // structured, redacted, fingerprinted

Table of contents

Why errx

Idiomatic Go error handling loses information at every boundary. A database error becomes a string, the string becomes an HTTP 500, the 500 tells the user nothing and the on-call engineer even less. Existing libraries each solve a slice — cockroachdb/errors has wire portability and redaction but is heavy and protobuf-coupled; samber/oops has great DX but no OpenTelemetry, gRPC, or wire format. No Go library ships the coherent union. errx does:

  • Stable identity — domain + code, separate from the human message, so dashboards and clients branch on it even when wording changes.
  • Expected / Defect / Cancelled class — middleware returns 4xx + retry, alerts on bugs, stays quiet on client-cancelled.
  • Operator vs end-user message — internal detail never leaks to clients.
  • Typed structured fields with per-field redaction — safe-by-default at any trust boundary; no redactable-string tax.
  • Retry & severity on the error itself — resilience and logging layers read it, not a type switch.
  • Lazy single-alloc stack + return-trace — ~15 ns capture, symbolized only when printed; plus where the error traveled.
  • Suppressed errors — a failing defer Close() never masks the real failure (a classic Go bug Go has no stdlib fix for).
  • Two-mode accumulation — collect every validation failure with field paths, not just the first.
  • Deterministic fingerprint — the same bug groups identically in Sentry, Prometheus, logs, and on the wire.
  • Cross-service JSON codec — serialize an error, reconstruct the typed error on the other side; identity survives, unsafe fields never serialize.
  • Compiler-grade diagnostics — source spans with caret underlines, error codes, doc URLs, dual graphical/narratable renderers.
  • i18n — BCP-47 localized end-user messages with language fallback.
  • Zero-dependency core, explicit adapters, no init().

See docs/comparison.md for the feature-by-feature matrix versus stdlib, pkg/errors, cockroachdb/errors, samber/oops, eris and gravitational/trace, and the explicit non-goals.

Install

Requires Go 1.24+.

go get github.com/ubgo/errx

Each integration is a separate module — install only what you use:

go get github.com/ubgo/errx/contrib/httpx        # RFC 9457 problem+json (zero extra deps)
go get github.com/ubgo/errx/contrib/grpc         # google.rpc.Status + error details
go get github.com/ubgo/errx/contrib/connect      # ConnectRPC
go get github.com/ubgo/errx/contrib/graphql      # gqlgen error presenter
go get github.com/ubgo/errx/contrib/sentry       # Sentry capture
go get github.com/ubgo/errx/contrib/otel         # OpenTelemetry
go get github.com/ubgo/errx/contrib/prometheus   # error metrics
go get github.com/ubgo/errx/contrib/goerr        # migration from github.com/ubgo/goerr

The core imports nothing beyond the Go standard library. You only pull a third-party dependency when you import the matching contrib module.

Quick start

package main

import (
	"fmt"

	"github.com/ubgo/errx"
)

func loadOrder(id string) error {
	// ... db call fails ...
	return errx.New("db: no rows for order " + id).
		WithDomain("orders").
		WithCode("NOT_FOUND").
		WithPublic("Order not found").
		WithHint("verify the order id").
		WithSafe("order_id", id)
}

func main() {
	err := loadOrder("o-42")

	fmt.Println(err.Error())                    // operator message (logs)
	fmt.Println(errx.Get(err).Public("error"))  // end-user-safe message
	fmt.Println(errx.Code(err))                 // NOT_FOUND
	fmt.Println(errx.Get(err).Fingerprint())    // stable grouping key
}

Core concepts

Construction and wrapping
Function Purpose
errx.New(msg) / errx.Newf(fmt, …) Create a leaf error with an origin stack.
errx.Wrap(err, msg) / errx.Wrapf(err, fmt, …) Annotate a cause. Transparent by default (errors.Is/As traverse); inner identity/class inherited. Returns nil if err is nil.
errx.Note(err, key, value) Attach a breadcrumb without a new wrapper frame — the cure for fmt.Errorf("…: %w") spam.
errx.Join(errs…) Bundle errors; errors.Is/As match any member.
.Opaque() Make the error a barrier so errors.Is/As do not see the cause (deliberately hide a dependency's sentinels).
Identity, class, severity
errx.New("…").
    WithDomain("billing").          // namespace
    WithCode("CARD_DECLINED").      // stable machine code
    WithClass(errx.ClassExpected).  // ClassExpected | ClassDefect | ClassCancelled
    WithSeverity(errx.SevWarn).     // SevDebug | SevInfo | SevWarn | SevError | SevFatal
    WithRetryable(3 * time.Second). // retryable + suggested delay
    WithOwner("payments")           // routing/alerting

Read them back from anywhere in a chain: errx.Code(err), errx.HasCode(err, "X"), errx.FindByCode(err, "X"), errx.ClassOf(err), errx.IsExpected/IsDefect/IsCancelled(err), errx.IsRetryable(err), errx.RetryAfter(err), errx.Fingerprint(err). errx.Code / HasCode also recognise any error exposing a Code() string method, so foreign and generated typed errors are first-class.

Messages: operator vs end-user vs localized
errx.New("pq: deadlock detected on user_orders"). // operator (logs, .Error())
    WithPublic("Something went wrong, please retry"). // end-user-safe
    WithLocalized("fr-FR", "Une erreur est survenue"). // i18n (BCP-47)
    WithLocalized("ja", "エラーが発生しました")

e := errx.Get(err)
e.Public("fallback")          // end-user message, never the operator detail
e.Localized("fr-CA", "fb")    // → French message via language fallback (fr-CA → fr-FR)
Structured fields and redaction (safe-by-default)
errx.New("charge failed").
    WithSafe("order_id", "o-1").  // safe → appears in logs, problem+json, Sentry, wire
    With("card_pan", pan)         // UNSAFE → replaced with ‹redacted› at every boundary

Redaction is enforced once, in Snapshot, before any sink or wire encoder sees the data — safe by construction, with none of the redactable-string ergonomic cost.

Suppressed errors and error-path cleanup
func process(path string) (err error) {
	f, err := os.Open(path)
	if err != nil {
		return errx.Wrap(err, "open input").WithCode("IO")
	}
	defer errx.CloseSuppressing(&err, f)            // close error becomes secondary, never masks
	defer errx.OnError(&err, func() { rollback() }) // runs only on the failure path (Zig errdefer)
	return parse(f)
}

errx.OnSuccess, errx.AppendSuppressed are also available.

Accumulation — collect every failure
acc := errx.NewAccumulator()
acc.Add("name", validateName(in.Name))
acc.Add("email", validateEmail(in.Email))
acc.AddErr(checkQuota(ctx))
return acc.ErrorOrNil() // nil, or one VALIDATION error listing every failure with its field path

errx.Accumulate(fns…) and the concurrent errx.ParAccumulate(fns…) are the functional forms.

Panic recovery
func handler() (err error) {
	defer errx.Recover(&err) // panic → ClassDefect/SevFatal error, original preserved as cause
	return risky()
}
// errx.RecoverDo(fn) and errx.Recovered(err) are also provided.
Cross-service wire codec
blob, _ := errx.Encode(err)   // JSON; unsafe fields are NOT serialized
got, _  := errx.Decode(blob)  // typed *errx.Error rebuilt
errx.HasCode(got, "QUOTA")    // identity survived the hop
got.Fingerprint() == err...   // same fingerprint → groups together everywhere
errx.RegisterCodeMigration("OLD_CODE", "NEW_CODE") // cross-version skew tolerance

Full API tour

Area Symbols
Construct New Newf Wrap Wrapf Note Join
Identity WithDomain WithCode WithClass WithSeverity WithRetryable WithOwner
Messages WithPublic WithHint WithLocalized · Public Remediation Localized LocaleMessages
Fields With WithSafe · Fields
Inspect Get Code HasCode FindByCode As[T] ClassOf IsExpected IsDefect IsCancelled IsRetryable RetryAfter Fingerprint
Stack Frames (origin) · Trace (return-trace)
Suppress / cleanup Suppress OnError OnSuccess CloseSuppressing AppendSuppressed
Accumulate NewAccumulator Accumulate ParAccumulate
Panic Recover RecoverDo Recovered
Context RegisterContextExtractor · Error.Context(ctx)
Reporting Snapshot Sink NewRegistry Registry.Add Registry.Report NewSampledSink PerFingerprint
Wire Encode Decode RegisterCodeMigration · json.Marshaler
Diagnostics WithURL WithSource WithLabel · RegisterDoc DocFor · diag subpackage
Barriers Opaque

Full generated reference: pkg.go.dev/github.com/ubgo/errx.

Integrations (contrib)

Each is a standalone module with its own README, step-by-step setup and runnable example:

Module Use it for README
contrib/httpx RFC 9457 application/problem+json HTTP responses (zero extra deps) docs
contrib/grpc google.rpc.Status + ErrorInfo/RetryInfo/LocalizedMessage/Help docs
contrib/connect ConnectRPC errors with typed details docs
contrib/graphql gqlgen ErrorPresenter (extensions.code) docs
contrib/sentry Sentry capture with the core fingerprint as the grouping key docs
contrib/otel OpenTelemetry exception span events + trace/span correlation docs
contrib/prometheus errx_errors_total metrics by stable identity docs
contrib/goerr Incremental migration from github.com/ubgo/goerr docs

Observability wiring

Wire sinks explicitly, once, at startup — no init() side effects:

reg := errx.NewRegistry().
	Add(errx.NewSampledSink(sentryx.New(hub), errx.PerFingerprint(time.Minute, 5))).
	Add(otelx.NewSink()).
	Add(promx.New(prometheus.DefaultRegisterer))
otelx.Install() // trace/span ids auto-attach via Error.Context(ctx)

// in your error middleware:
reg.Report(ctx, err)

SampledSink rate-limits per fingerprint so one hot error cannot blow your Sentry bill, and surfaces an errx.sampled_dropped count instead of dropping silently.

Diagnostics (miette-style)

err := errx.New(`unknown column "emial"`).
	WithCode("PG_UNDEFINED_COLUMN").
	WithSource("query.sql", sql).
	WithLabel(strings.Index(sql, "emial"), 5, "no such column")
errx.RegisterDoc("PG_UNDEFINED_COLUMN", errx.DocEntry{
	URL:         "https://errors.example.com/PG_UNDEFINED_COLUMN",
	Remediation: `did you mean "email"?`,
})

fmt.Println(diag.String(err))
error[PG_UNDEFINED_COLUMN]: unknown column "emial"
  --> query.sql
3 | WHERE  emial = $1
  |        ^^^^^ no such column
help: did you mean "email"?
docs: https://errors.example.com/PG_UNDEFINED_COLUMN

diag.Fprint(os.Stderr, err, diag.Auto()) auto-switches to a prose narratable form under NO_COLOR / CI / non-TTY (screen-reader friendly).

Code generation — errxgen

Go has no derive macros. cmd/errxgen generates the boilerplate; generated code imports only fmt:

//go:generate go run github.com/ubgo/errx/cmd/errxgen .

//errxgen: message="open %s: permission denied (uid %d)" args=Path,UID code=IO_DENIED unwrap=Err
type DeniedError struct {
	Path string
	UID  int
	Err  error
}

go generate ./... emits Error(), Code() and Unwrap(). Because errx.Code/HasCode honor any Code() string method, the generated type is first-class without being an *errx.Error.

Optional Result[T] layer

For code that prefers railway style — never required, idiomatic (value, error) stays primary:

import "github.com/ubgo/errx/result"

r := result.Try(func() (User, error) { return repo.Find(id) })
name := result.Map(r, func(u User) string { return u.Name }).UnwrapOr("anonymous")

Testing helpers

import "github.com/ubgo/errx/errtest"

errtest.Code(t, err, "NOT_FOUND")
errtest.Class(t, err, errx.ClassExpected)
errtest.Retryable(t, err, true)
errtest.InChain(t, err, io.ErrUnexpectedEOF)
errtest.Fingerprint(t, errA, errB) // assert two errors group together

FAQ

Is it really a drop-in over the standard library? Yes. errx.Wrap works with errors.Is, errors.As, errors.Join. *errx.Error implements Unwrap() []error. You can adopt it incrementally.

Does the core pull dependencies? No. The core module is standard-library-only. A third-party dependency enters your build only when you import the matching contrib module.

Will it bloat like cockroachdb/errors? That is the explicit anti-goal. Core stays tiny; wire codec, redaction, and every vendor integration are opt-in. You pay for what you import.

Is this a monad / Result framework? No. result is an optional sub-package. The primary interface is idiomatic Go errors.

Does it replace github.com/ubgo/goerr? No — that thin module stays. contrib/goerr is a two-way migration bridge.

Performance? Stack capture is a single runtime.Callers storing []uintptr, symbolized only when printed (~15 ns vs pkg/errors ~717 ns / 3 allocs). No capture happens unless you construct an errx error.

Documentation

  • docs/design.md — architecture: the Report seam, zero-dep core, no-init(), wrapping/class/stack rationale.
  • docs/comparison.md — feature matrix vs the field, plus explicit non-goals and migration paths.
  • docs/use-cases.md — copy-pasteable patterns (HTTP, gRPC, validation, retry, cross-service, observability, diagnostics).
  • docs/snippets.md — one-screen cheat sheet.
  • Per-integration guides under contrib/*/README.md.

Community

License

Apache-2.0 — see LICENSE and NOTICE. Part of the github.com/ubgo ecosystem of small, focused, dependency-light Go libraries.

Documentation

Overview

Package errx is a structured error type for Go: one coherent value carrying a stable machine identity (domain + code), an expected/defect/cancelled class, a separate operator and end-user message, typed structured fields with per-field redaction, retry and severity metadata, a lazily-captured stack, suppressed (secondary) errors, and a deterministic fingerprint — while staying a drop-in over the standard library's errors.Is/As/Join.

The core (this package) imports nothing beyond the standard library. Observability and transport integrations live under contrib/* as separate modules that consume the neutral Report DTO via Snapshot; the core never imports them and there is no init() magic — registration is explicit.

Example
package main

import (
	"fmt"

	"github.com/ubgo/errx"
)

func main() {
	err := errx.New("db: deadlock on user_orders").
		WithDomain("billing").
		WithCode("TX_RETRY").
		WithPublic("Something went wrong, please retry").
		WithRetryable(0)

	fmt.Println(err.Error())
	fmt.Println(err.Public("unknown"))
	fmt.Println(errx.Code(err))
	fmt.Println(errx.IsRetryable(err))
}
Output:
db: deadlock on user_orders
Something went wrong, please retry
TX_RETRY
true

Index

Examples

Constants

View Source
const RedactedMarker = "‹redacted›"

RedactedMarker replaces the value of any field not marked Safe when the error is projected to a Report.

Variables

This section is empty.

Functions

func Accumulate

func Accumulate(fns ...func() error) error

Accumulate runs each fn fail-soft, collecting every error, and returns nil or one combined *Error. Use it for independent checks where you want all failures, not just the first.

Example
package main

import (
	"errors"
	"fmt"

	"github.com/ubgo/errx"
)

func main() {
	err := errx.Accumulate(
		func() error { return nil },
		func() error { return errors.New("name is required") },
		func() error { return errors.New("email is invalid") },
	)
	fmt.Println(errx.Code(err))
	fmt.Println(len(errx.Get(err).Suppressed()))
}
Output:
VALIDATION
2

func AppendSuppressed

func AppendSuppressed(errp *error, extra error)

AppendSuppressed records extra as a secondary error on *errp without letting it mask the primary. If *errp is nil, extra becomes the error; if *errp is an *Error, extra is attached via Suppress; otherwise the pair is joined.

func As

func As[T error](err error) (T, bool)

As is a generic, one-line errors.As: pull the first error of type T out of the chain without pre-declaring a variable.

if pe, ok := errx.As[*fs.PathError](err); ok { _ = pe.Path }

func CloseSuppressing

func CloseSuppressing(errp *error, c io.Closer)

CloseSuppressing closes c and folds any close error into *errp as a SECONDARY error, so a failing Close never masks the real failure (the classic `defer f.Close()` bug Go has no stdlib remedy for):

defer errx.CloseSuppressing(&err, f)

func Code

func Code(err error) string

Code returns the code of the first error in the chain carrying one — either an *errx.Error or any error exposing a `Code() string` method (so errxgen-generated and foreign typed errors are first-class without being errx.Error). Behavioral detection, segmentio-style.

func Encode

func Encode(err error) ([]byte, error)

Encode serializes err to the JSON wire form. err should contain an *Error; otherwise a minimal envelope with just the message is produced.

Example
package main

import (
	"fmt"

	"github.com/ubgo/errx"
)

func main() {
	orig := errx.New("internal detail").
		WithDomain("billing").WithCode("NOT_FOUND").
		WithPublic("Order not found").
		With("password", "hunter2") // unsafe: never serialized

	blob, _ := errx.Encode(orig)
	got, _ := errx.Decode(blob)

	fmt.Println(errx.Code(got))
	fmt.Println(got.Public("x"))
	fmt.Println(got.Fingerprint() == orig.Fingerprint())
}
Output:
NOT_FOUND
Order not found
true

func Fingerprint

func Fingerprint(err error) string

Fingerprint returns the grouping key of the nearest *Error in err, or "".

func HasCode

func HasCode(err error, code string) bool

HasCode reports whether any *Error in the chain has the given code.

func IsCancelled

func IsCancelled(err error) bool

func IsDefect

func IsDefect(err error) bool

func IsExpected

func IsExpected(err error) bool

IsExpected / IsDefect / IsCancelled are convenience predicates over ClassOf.

func IsRetryable

func IsRetryable(err error) bool

IsRetryable reports whether the nearest *Error is marked retryable. It also treats context.DeadlineExceeded as non-retryable by default (the caller's deadline is gone), mirroring the AIP-194 stance.

func Join

func Join(errs ...error) error

Join bundles multiple errors into one, discarding nils. The result implements Unwrap() []error so errors.Is/As match any member. Returns nil if every input is nil, and the sole error unchanged if only one is non-nil.

func Note

func Note(err error, key string, value any) error

Note attaches a breadcrumb field to an existing error WITHOUT adding a wrapper frame — the answer to fmt.Errorf("...: %w") spam. If err is an *Error the note is appended in place and the same error is returned; for any other error a thin *Error wrapper carries the note.

errx.Note(err, "attempt", 3)
errx.Note(err, "tenant", tenantID)
Example
package main

import (
	"fmt"

	"github.com/ubgo/errx"
)

func main() {
	base := errx.New("upstream timeout").WithCode("TIMEOUT")
	_ = errx.Note(base, "attempt", 3) // no extra wrapper frame
	for _, f := range base.Fields() {
		fmt.Printf("%s=%v\n", f.Key, f.Value)
	}
}
Output:
attempt=3

func OnError

func OnError(errp *error, cleanup func())

OnError runs cleanup ONLY if *errp is non-nil when the enclosing function returns — the Zig `errdefer` semantics Go's plain `defer` cannot express without a hand-written `if *errp != nil` inside every deferred closure. Use it to roll back partial work on the failure path:

func write() (err error) {
	f, err := os.Create(path)
	if err != nil { return err }
	defer errx.OnError(&err, func() { os.Remove(path) }) // undo on failure
	defer f.Close()
	return marshal(f)
}

func OnSuccess

func OnSuccess(errp *error, fn func())

OnSuccess runs fn ONLY if *errp is nil when the function returns.

func ParAccumulate

func ParAccumulate(fns ...func() error) error

ParAccumulate runs every fn concurrently and collects all failures.

func Recover

func Recover(errp *error)

Recover converts a panic into a structured *Error (ClassDefect, SevFatal) and assigns it to *errp. Use it deferred so one bad input degrades to an error instead of crashing the process:

func handle() (err error) {
	defer errx.Recover(&err)
	mightPanic()
	return nil
}

If *errp already holds an error it is preserved as the cause.

func RecoverDo

func RecoverDo(fn func() error) (err error)

RecoverDo runs fn and returns a structured *Error if it panics.

func Recovered

func Recovered(err error) bool

Recovered reports whether err is a panic captured by this package.

func RegisterCodeMigration

func RegisterCodeMigration(oldCode, newCode string)

RegisterCodeMigration maps a retired error code to its replacement. Decode rewrites old to new, so a newer service still recognises errors minted by an older one (and vice versa once both register the pair) — the cross-version-skew tolerance cockroachdb/errors gets from RegisterTypeMigration, here for the JSON wire form. Explicit, no init().

func RegisterContextExtractor

func RegisterContextExtractor(fn ContextExtractor)

RegisterContextExtractor adds an extractor consulted by Error.Context. Call it once at process start (e.g. from an adapter's wiring), never via init().

func RegisterDoc

func RegisterDoc(code string, d DocEntry)

RegisterDoc records documentation for an error code. Explicit registration (call once at startup); no init() magic.

func RetryAfter

func RetryAfter(err error) time.Duration

RetryAfter returns the suggested retry delay from the nearest *Error.

Types

type Accumulator

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

Accumulator collects multiple failures instead of stopping at the first — the validation gap in Go. Each entry can carry a field path so a whole form/config/batch is validated in one pass. Goroutine-safe, so it doubles as the concurrent (parallel) accumulator.

acc := errx.NewAccumulator()
acc.Add("name", validateName(in.Name))
acc.Add("email", validateEmail(in.Email))
acc.AddErr(checkQuota(ctx))
return acc.ErrorOrNil() // nil, or one error listing every failure

func NewAccumulator

func NewAccumulator() *Accumulator

NewAccumulator returns an empty accumulator.

func (*Accumulator) Add

func (a *Accumulator) Add(path string, err error)

Add records err under a field path (e.g. "items[2].price"). No-op if nil.

func (*Accumulator) AddErr

func (a *Accumulator) AddErr(err error)

AddErr records err with no path. No-op if nil.

func (*Accumulator) ErrorOrNil

func (a *Accumulator) ErrorOrNil() error

ErrorOrNil returns nil if nothing was collected, otherwise a single *Error (code "VALIDATION", ClassExpected) whose suppressed list holds every failure and whose fields hold the path → message map.

func (*Accumulator) Len

func (a *Accumulator) Len() int

Len returns how many failures have been collected.

type Class

type Class uint8

Class is the coarse handling category of an error. Middleware and observability layers branch on it: return 4xx + maybe retry for Expected, alert + 5xx for Defect, stay quiet for Cancelled.

const (
	// ClassExpected is a normal, anticipated failure the caller can handle
	// (validation failed, not found, conflict). The default.
	ClassExpected Class = iota
	// ClassDefect is a bug or invariant violation — it should never happen
	// and warrants an alert, not a user-facing 4xx.
	ClassDefect
	// ClassCancelled is work abandoned because the caller went away
	// (context cancelled / deadline exceeded). Usually not worth logging loudly.
	ClassCancelled
)

func ClassOf

func ClassOf(err error) Class

ClassOf returns the class of the nearest *Error in the chain, or ClassCancelled if the chain contains context cancellation/deadline, else ClassExpected.

func (Class) String

func (c Class) String() string

type ContextExtractor

type ContextExtractor func(ctx context.Context) (traceID, spanID string, fields []Field)

ContextExtractor pulls correlation data out of a context.Context. The core is stdlib-only and cannot import OpenTelemetry; an otel contrib adapter registers an extractor explicitly (no init() magic) so Error.Context(ctx) can attach trace/span ids and request-scoped fields.

type DocEntry

type DocEntry struct {
	URL         string
	Summary     string
	Remediation string
}

DocEntry is the documentation for an error code: a stable doc URL, a one-line summary, and operator remediation guidance. Registering codes centrally means an error only needs to carry its code — the human-facing docs/URL/remediation are resolved from the registry (miette's url() idea + a code catalog), and stay consistent everywhere the code appears.

func DocFor

func DocFor(code string) (DocEntry, bool)

DocFor returns the registered documentation for a code.

type Error

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

Error is the core structured error value. It is an ordinary Go error; generics are never required to use it.

func Decode

func Decode(data []byte) (*Error, error)

Decode reconstructs a typed *Error from the wire form. The cause and any suppressed errors come back as opaque errors.New values carrying their original messages (remote concrete types are not importable), but the reconstructed error keeps its identity so errx.Code / HasCode / IsRetryable / ClassOf behave the same across the boundary.

func FindByCode

func FindByCode(err error, code string) *Error

FindByCode returns the first *Error in the chain with the given code.

func Get

func Get(err error) *Error

Get returns the first *Error in the chain, or nil.

func New

func New(msg string) *Error

New creates a leaf error with an operator message and an origin stack. Defaults: ClassExpected, SevError. Chain builders to enrich it:

return errx.New("db: deadlock on user_orders").
	WithDomain("billing").WithCode("TX_RETRY").
	WithPublic("Something went wrong, please retry").
	WithRetryable(0)

func Newf

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

Newf is New with fmt.Sprintf formatting.

func Wrap

func Wrap(err error, msg string) *Error

Wrap annotates an existing error with an operator message while keeping the cause matchable via errors.Is/As (transparent by default; call Opaque() to make it a barrier). Returns nil if err is nil so it is safe at the tail of a function:

if err != nil { return errx.Wrap(err, "load config") }

If err is already an *Error and msg is empty, Wrap inherits its identity instead of nesting redundantly.

Example
package main

import (
	"errors"
	"fmt"

	"github.com/ubgo/errx"
)

func main() {
	sentinel := errors.New("pq: deadlock")
	err := errx.Wrap(sentinel, "load orders").WithCode("DB_READ")

	fmt.Println(err.Error())
	fmt.Println(errors.Is(err, sentinel)) // transparent by default
	fmt.Println(errx.Code(err))
}
Output:
load orders: pq: deadlock
true
DB_READ

func Wrapf

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

Wrapf is Wrap with fmt.Sprintf formatting.

func (*Error) Cause

func (e *Error) Cause() error

func (*Error) ClassOf

func (e *Error) ClassOf() Class

func (*Error) Code

func (e *Error) Code() string

func (*Error) Context

func (e *Error) Context(ctx context.Context) *Error

Context enriches the error from ctx via the registered extractors: trace/span ids (only if not already set) plus any request-scoped fields. No-op when ctx is nil or no extractor is registered.

func (*Error) Domain

func (e *Error) Domain() string

func (*Error) Error

func (e *Error) Error() string

Error implements the error interface, returning the operator message.

func (*Error) Fields

func (e *Error) Fields() []Field

Fields returns a copy of the structured fields (defensive).

func (*Error) Fingerprint

func (e *Error) Fingerprint() string

Fingerprint returns a deterministic, message-independent grouping key: FNV-1a of domain + code + the origin frame (function:line). It is stable across instances and processes, so the SAME bug groups identically in Sentry, the Prometheus label, the log field, and the wire — solving the "four disconnected grouping mechanisms" problem. High-cardinality data in the message never perturbs it.

func (*Error) Format

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

Format implements fmt.Formatter.

%s / %v  operator message (chain)
%q       quoted operator message
%+v      message + identity + fields + origin stack + suppressed

func (*Error) Frames

func (e *Error) Frames() []Frame

Frames symbolizes the captured origin stack lazily. Returns nil if the error carries no stack.

func (*Error) Hint

func (e *Error) Hint() string

func (*Error) Labels

func (e *Error) Labels() []Label

Labels returns a copy of the diagnostic labels.

func (*Error) LocaleMessages

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

LocaleMessages returns a copy of all locale-specific messages.

func (*Error) Localized

func (e *Error) Localized(locale, fallback string) string

Localized returns the message for locale, falling back to a same-language match (e.g. "fr" for "fr-CA"), then the Public message, then fallback.

func (*Error) LogValue

func (e *Error) LogValue() slog.Value

LogValue lets the error describe itself to log/slog as a structured group — code, domain, class, severity, fingerprint, retryable, and the safe fields — instead of being stringified into the message. Unsafe fields are redacted, so structured logs are safe by construction.

slog.Error("request failed", "err", err) // err is *errx.Error

func (*Error) MarshalJSON

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

MarshalJSON implements json.Marshaler so json.Marshal(err) yields the wire form. Unsafe fields are omitted entirely (never serialized).

func (*Error) Opaque

func (e *Error) Opaque() *Error

Opaque turns this error into a barrier: errors.Is/As will not see the wrapped cause. Use deliberately to stop a dependency's sentinels leaking into your package's API surface.

Example
package main

import (
	"errors"
	"fmt"

	"github.com/ubgo/errx"
)

func main() {
	sentinel := errors.New("secret dependency sentinel")
	err := errx.Wrap(sentinel, "public boundary").Opaque()

	fmt.Println(errors.Is(err, sentinel)) // barrier hides the cause
}
Output:
false

func (*Error) Owner

func (e *Error) Owner() string

func (*Error) Public

func (e *Error) Public(fallback string) string

Public returns the end-user-safe message, falling back to fallback when none was set so callers never accidentally surface the operator message.

func (*Error) Remediation

func (e *Error) Remediation() string

Remediation returns operator guidance: the Hint set on the error, else the remediation registered for its code.

func (*Error) RetryAfter

func (e *Error) RetryAfter() time.Duration

func (*Error) Retryable

func (e *Error) Retryable() bool

func (*Error) SeverityOf

func (e *Error) SeverityOf() Severity

func (*Error) Source

func (e *Error) Source() (name, content string)

Source returns the diagnostic source name and content.

func (*Error) SpanID

func (e *Error) SpanID() string

func (*Error) Suppress

func (e *Error) Suppress(errs ...error) *Error

Suppress records a secondary error (a cleanup/Close failure during error unwind) alongside the primary, instead of one masking the other. The suppressed errors remain matchable via errors.Is/As.

func (*Error) Suppressed

func (e *Error) Suppressed() []error

func (*Error) Trace

func (e *Error) Trace() []Frame

Trace returns the return-trace: one frame per errx layer that wrapped the error, innermost-first — the path the error traveled up the call stack, distinct from the origin Stack. Cheap (one PC per Wrap) and works across goroutine boundaries since it is recorded on the value, not sampled.

func (*Error) TraceID

func (e *Error) TraceID() string

func (*Error) URL

func (e *Error) URL() string

URL returns the documentation URL: the one set via WithURL, else the one registered for this error's code (see RegisterDoc), else "".

func (*Error) Unwrap

func (e *Error) Unwrap() []error

Unwrap exposes the wrapped cause plus any suppressed errors so errors.Is/As traverse the whole chain (Go 1.20 multi-unwrap form). An Opaque() error returns nil here — a deliberate barrier that hides a dependency's sentinels from callers. Note: like errors.Join, the single errors.Unwrap returns nil for this type; use Cause() for the primary cause.

func (*Error) With

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

With attaches an unsafe (redacted at a trust boundary) structured field.

func (*Error) WithClass

func (e *Error) WithClass(c Class) *Error

func (*Error) WithCode

func (e *Error) WithCode(c string) *Error

func (*Error) WithDomain

func (e *Error) WithDomain(d string) *Error

func (*Error) WithHint

func (e *Error) WithHint(h string) *Error

func (*Error) WithLabel

func (e *Error) WithLabel(start, length int, msg string) *Error

WithLabel adds a labeled span over the source set by WithSource.

func (*Error) WithLocalized

func (e *Error) WithLocalized(locale, msg string) *Error

WithLocalized attaches an end-user message for a BCP-47 locale (e.g. "fr-FR"). The locale-free Public message remains the default.

func (*Error) WithOwner

func (e *Error) WithOwner(o string) *Error

func (*Error) WithPublic

func (e *Error) WithPublic(m string) *Error

func (*Error) WithRetryable

func (e *Error) WithRetryable(after time.Duration) *Error

WithRetryable marks the error retryable and, optionally, after how long.

func (*Error) WithSafe

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

WithSafe attaches a field explicitly marked non-sensitive (never redacted).

func (*Error) WithSeverity

func (e *Error) WithSeverity(s Severity) *Error

func (*Error) WithSource

func (e *Error) WithSource(name, content string) *Error

WithSource attaches the source text that diagnostic labels index into (a query, a config file, a DSL snippet — whatever produced the error).

func (*Error) WithTrace

func (e *Error) WithTrace(traceID, spanID string) *Error

func (*Error) WithURL

func (e *Error) WithURL(url string) *Error

WithURL attaches a documentation URL for this error's code, rendered as a clickable reference by the diag package (miette's url(docsrs) idea).

type Field

type Field struct {
	Key   string
	Value any
	Safe  bool
}

Field is one structured key/value attached to an error. Safe marks the value as non-sensitive; everything is treated as unsafe (redacted at a trust boundary) unless explicitly marked Safe — safe-by-default redaction without the redactable-string ergonomic tax.

type Frame

type Frame struct {
	Function string
	File     string
	Line     int
}

Frame is a symbolized stack frame.

type Label

type Label struct {
	Start int
	Len   int
	Msg   string
}

Label is a source span annotation: [Start, Start+Len) into the source set by WithSource, with a short message rendered under a caret underline.

type Registry

type Registry struct {
	OnSinkError func(name string, err error)
	// contains filtered or unexported fields
}

Registry fans a Report out to registered sinks. Registration is explicit (caller wires it) — there is no init() magic. Per-sink Emit errors are swallowed so one bad sink cannot break reporting; SinkError records them for observability of the observability.

func NewRegistry

func NewRegistry() *Registry

NewRegistry returns an empty registry.

func (*Registry) Add

func (r *Registry) Add(s Sink) *Registry

Add registers a sink. Returns the registry for chaining.

func (*Registry) Report

func (r *Registry) Report(ctx context.Context, err error)

Report projects err and emits it to every sink. No-op if err carries no *Error. Safe for concurrent use.

type Report

type Report struct {
	Domain      string
	Code        string
	Class       Class
	Severity    Severity
	Message     string // operator message
	Public      string // end-user-safe message
	Hint        string
	Owner       string
	DocURL      string
	Localized   map[string]string // BCP-47 locale -> message
	Fingerprint string
	Retryable   bool
	RetryAfter  string // human duration, "" if none
	TraceID     string
	SpanID      string
	Fields      []Field // unsafe values already replaced with redacted marker
	Stack       []Frame // origin stack
	Trace       []Frame // return-trace: where the error traveled (per-wrap)
	Suppressed  []string
	Cause       string
}

Report is the neutral, serializable projection of an *Error. Every contrib adapter (sentry, otel, http, grpc, prometheus, ...) consumes a Report and nothing else — adapters never touch *Error internals and the core never imports an adapter. This is the single seam (ERR-ADR-001).

Unsafe fields are redacted here, so any sink that crosses a trust boundary is safe by construction.

func Snapshot

func Snapshot(ctx context.Context, err error) (Report, bool)

Snapshot projects the nearest *Error in err into a Report, resolving the lazy stack and redacting unsafe fields. ctx is accepted for future trace-extraction hooks; nil is fine.

type SamplePolicy

type SamplePolicy struct {
	Window time.Duration
	Burst  int
}

SamplePolicy configures a SampledSink.

func PerFingerprint

func PerFingerprint(window time.Duration, burst int) SamplePolicy

PerFingerprint builds a policy allowing burst reports per window per distinct fingerprint.

type SampledSink

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

SampledSink wraps a Sink with per-fingerprint rate limiting: when one logical error fires thousands of times a minute it forwards at most `burst` reports per `window` for that fingerprint and drops the rest, so a single hot error cannot blow the Sentry bill or bury rare errors. Different fingerprints are limited independently. Reports with no fingerprint are always forwarded.

reg := errx.NewRegistry().Add(
	errx.SampledSink(sentry.New(client), errx.PerFingerprint(time.Minute, 5)))

func NewSampledSink

func NewSampledSink(inner Sink, p SamplePolicy) *SampledSink

NewSampledSink wraps inner with the given policy. A non-positive burst or window disables sampling (everything passes through).

func (*SampledSink) Emit

func (s *SampledSink) Emit(ctx context.Context, r *Report) error

Emit forwards the report to the inner sink unless this fingerprint is over its budget for the current window. When a new window opens it attaches a "dropped" field recording how many were suppressed, so the suppression is visible rather than silent.

func (*SampledSink) Name

func (s *SampledSink) Name() string

Name returns the inner sink's name.

type Severity

type Severity uint8

Severity is the log/alert level carried by an error so sinks route it without re-deriving it from the message.

const (
	SevDebug Severity = iota
	SevInfo
	SevWarn
	SevError // default
	SevFatal
)

func (Severity) String

func (s Severity) String() string

type Sink

type Sink interface {
	Name() string
	Emit(ctx context.Context, r *Report) error
}

Sink receives projected error Reports. Implementations live in contrib/*.

Directories

Path Synopsis
cmd
errxgen command
Command errxgen generates error boilerplate from a declarative directive, closing Go's biggest ergonomic gap vs Rust's thiserror (Go has no derive macros).
Command errxgen generates error boilerplate from a declarative directive, closing Go's biggest ergonomic gap vs Rust's thiserror (Go has no derive macros).
errxgen/example
Package example demonstrates errxgen.
Package example demonstrates errxgen.
contrib
connect module
goerr module
graphql module
grpc module
httpx module
otel module
prometheus module
sentry module
Package diag renders an errx error as a compiler-grade diagnostic: severity + code + message, a documentation URL, a hint, and — when the error carries source via WithSource/WithLabel — the offending source line(s) with caret underlines (miette's GraphicalReportHandler), plus a NarratableReportHandler that describes the diagnostic in prose for CI, pipes, and screen readers.
Package diag renders an errx error as a compiler-grade diagnostic: severity + code + message, a documentation URL, a hint, and — when the error carries source via WithSource/WithLabel — the offending source line(s) with caret underlines (miette's GraphicalReportHandler), plus a NarratableReportHandler that describes the diagnostic in prose for CI, pipes, and screen readers.
Package errtest provides assertion helpers for testing errx-based errors: check a code, class, retryability, or chain membership without hand-rolling errors.As plumbing in every test.
Package errtest provides assertion helpers for testing errx-based errors: check a code, class, retryability, or chain membership without hand-rolling errors.As plumbing in every test.
Package result is an OPTIONAL generics layer over errx for code that prefers a railway/Result style.
Package result is an OPTIONAL generics layer over errx for code that prefers a railway/Result style.

Jump to

Keyboard shortcuts

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