Skip to content

Latest commit

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date

errx — the structured error library for Go

Go ReferenceGo Report CardCIcodecovtaglicenseGoPRs welcomeConventional 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 boundaryhttpx.Write(w, r, err) // → 503 application/problem+jsonslog.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 identitydomain + 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"
)
funcloadOrder(idstring) error {
// ... db call fails ...returnerrx.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)
}
funcmain() {
err:=loadOrder("o-42")
fmt.Println(err.Error()) // operator message (logs)fmt.Println(errx.Get(err).Public("error")) // end-user-safe messagefmt.Println(errx.Code(err)) // NOT_FOUNDfmt.Println(errx.Get(err).Fingerprint()) // stable grouping key
}

Core concepts

Construction and wrapping

FunctionPurpose
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"). // namespaceWithCode("CARD_DECLINED"). // stable machine codeWithClass(errx.ClassExpected). // ClassExpected | ClassDefect | ClassCancelledWithSeverity(errx.SevWarn). // SevDebug | SevInfo | SevWarn | SevError | SevFatalWithRetryable(3*time.Second). // retryable + suggested delayWithOwner("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-safeWithLocalized("fr-FR", "Une erreur est survenue"). // i18n (BCP-47)WithLocalized("ja", "エラーが発生しました")
e:=errx.Get(err)
e.Public("fallback") // end-user message, never the operator detaile.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, wireWith("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

funcprocess(pathstring) (errerror) {
f, err:=os.Open(path)
iferr!=nil {
returnerrx.Wrap(err, "open input").WithCode("IO")
}
defererrx.CloseSuppressing(&err, f) // close error becomes secondary, never masksdefererrx.OnError(&err, func() { rollback() }) // runs only on the failure path (Zig errdefer)returnparse(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))
returnacc.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

funchandler() (errerror) {
defererrx.Recover(&err) // panic → ClassDefect/SevFatal error, original preserved as causereturnrisky()
}
// errx.RecoverDo(fn) and errx.Recovered(err) are also provided.

Cross-service wire codec

blob, _:=errx.Encode(err) // JSON; unsafe fields are NOT serializedgot, _:=errx.Decode(blob) // typed *errx.Error rebuilterrx.HasCode(got, "QUOTA") // identity survived the hopgot.Fingerprint() ==err... // same fingerprint → groups together everywhereerrx.RegisterCodeMigration("OLD_CODE", "NEW_CODE") // cross-version skew tolerance

Full API tour

AreaSymbols
ConstructNewNewfWrapWrapfNoteJoin
IdentityWithDomainWithCodeWithClassWithSeverityWithRetryableWithOwner
MessagesWithPublicWithHintWithLocalized · PublicRemediationLocalizedLocaleMessages
FieldsWithWithSafe · Fields
InspectGetCodeHasCodeFindByCodeAs[T]ClassOfIsExpectedIsDefectIsCancelledIsRetryableRetryAfterFingerprint
StackFrames (origin) · Trace (return-trace)
Suppress / cleanupSuppressOnErrorOnSuccessCloseSuppressingAppendSuppressed
AccumulateNewAccumulatorAccumulateParAccumulate
PanicRecoverRecoverDoRecovered
ContextRegisterContextExtractor · Error.Context(ctx)
ReportingSnapshotSinkNewRegistryRegistry.AddRegistry.ReportNewSampledSinkPerFingerprint
WireEncodeDecodeRegisterCodeMigration · json.Marshaler
DiagnosticsWithURLWithSourceWithLabel · RegisterDocDocFor · diag subpackage
BarriersOpaque

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:

ModuleUse it forREADME
contrib/httpxRFC 9457 application/problem+json HTTP responses (zero extra deps)docs
contrib/grpcgoogle.rpc.Status + ErrorInfo/RetryInfo/LocalizedMessage/Helpdocs
contrib/connectConnectRPC errors with typed detailsdocs
contrib/graphqlgqlgen ErrorPresenter (extensions.code)docs
contrib/sentrySentry capture with the core fingerprint as the grouping keydocs
contrib/otelOpenTelemetry exception span events + trace/span correlationdocs
contrib/prometheuserrx_errors_total metrics by stable identitydocs
contrib/goerrIncremental migration from github.com/ubgo/goerrdocs

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=ErrtypeDeniedErrorstruct {
PathstringUIDintErrerror
}

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) { returnrepo.Find(id) })
name:=result.Map(r, func(uUser) string { returnu.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

Community

License

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

About

One coherent structured error for Go: stable identity, class, redactable fields, return-trace, miette-style diagnostics, wire codec — zero-dep core, isolated contrib adapters.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages