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 ¶
- Constants
- func Accumulate(fns ...func() error) error
- func AppendSuppressed(errp *error, extra error)
- func As[T error](err error) (T, bool)
- func CloseSuppressing(errp *error, c io.Closer)
- func Code(err error) string
- func Encode(err error) ([]byte, error)
- func Fingerprint(err error) string
- func HasCode(err error, code string) bool
- func IsCancelled(err error) bool
- func IsDefect(err error) bool
- func IsExpected(err error) bool
- func IsRetryable(err error) bool
- func Join(errs ...error) error
- func Note(err error, key string, value any) error
- func OnError(errp *error, cleanup func())
- func OnSuccess(errp *error, fn func())
- func ParAccumulate(fns ...func() error) error
- func Recover(errp *error)
- func RecoverDo(fn func() error) (err error)
- func Recovered(err error) bool
- func RegisterCodeMigration(oldCode, newCode string)
- func RegisterContextExtractor(fn ContextExtractor)
- func RegisterDoc(code string, d DocEntry)
- func RetryAfter(err error) time.Duration
- type Accumulator
- type Class
- type ContextExtractor
- type DocEntry
- type Error
- func (e *Error) Cause() error
- func (e *Error) ClassOf() Class
- func (e *Error) Code() string
- func (e *Error) Context(ctx context.Context) *Error
- func (e *Error) Domain() string
- func (e *Error) Error() string
- func (e *Error) Fields() []Field
- func (e *Error) Fingerprint() string
- func (e *Error) Format(s fmt.State, verb rune)
- func (e *Error) Frames() []Frame
- func (e *Error) Hint() string
- func (e *Error) Labels() []Label
- func (e *Error) LocaleMessages() map[string]string
- func (e *Error) Localized(locale, fallback string) string
- func (e *Error) LogValue() slog.Value
- func (e *Error) MarshalJSON() ([]byte, error)
- func (e *Error) Opaque() *Error
- func (e *Error) Owner() string
- func (e *Error) Public(fallback string) string
- func (e *Error) Remediation() string
- func (e *Error) RetryAfter() time.Duration
- func (e *Error) Retryable() bool
- func (e *Error) SeverityOf() Severity
- func (e *Error) Source() (name, content string)
- func (e *Error) SpanID() string
- func (e *Error) Suppress(errs ...error) *Error
- func (e *Error) Suppressed() []error
- func (e *Error) Trace() []Frame
- func (e *Error) TraceID() string
- func (e *Error) URL() string
- func (e *Error) Unwrap() []error
- func (e *Error) With(key string, value any) *Error
- func (e *Error) WithClass(c Class) *Error
- func (e *Error) WithCode(c string) *Error
- func (e *Error) WithDomain(d string) *Error
- func (e *Error) WithHint(h string) *Error
- func (e *Error) WithLabel(start, length int, msg string) *Error
- func (e *Error) WithLocalized(locale, msg string) *Error
- func (e *Error) WithOwner(o string) *Error
- func (e *Error) WithPublic(m string) *Error
- func (e *Error) WithRetryable(after time.Duration) *Error
- func (e *Error) WithSafe(key string, value any) *Error
- func (e *Error) WithSeverity(s Severity) *Error
- func (e *Error) WithSource(name, content string) *Error
- func (e *Error) WithTrace(traceID, spanID string) *Error
- func (e *Error) WithURL(url string) *Error
- type Field
- type Frame
- type Label
- type Registry
- type Report
- type SamplePolicy
- type SampledSink
- type Severity
- type Sink
Examples ¶
Constants ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
Fingerprint returns the grouping key of the nearest *Error in err, or "".
func IsCancelled ¶
func IsExpected ¶
IsExpected / IsDefect / IsCancelled are convenience predicates over ClassOf.
func IsRetryable ¶
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 ¶
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 ¶
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 ¶
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 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 ¶
RegisterDoc records documentation for an error code. Explicit registration (call once at startup); no init() magic.
func RetryAfter ¶
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 )
type ContextExtractor ¶
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 ¶
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.
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 ¶
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 ¶
FindByCode returns the first *Error in the chain with the given code.
func New ¶
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 Wrap ¶
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 (*Error) Context ¶
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) Fingerprint ¶
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 ¶
Format implements fmt.Formatter.
%s / %v operator message (chain) %q quoted operator message %+v message + identity + fields + origin stack + suppressed
func (*Error) Frames ¶
Frames symbolizes the captured origin stack lazily. Returns nil if the error carries no stack.
func (*Error) LocaleMessages ¶
LocaleMessages returns a copy of all locale-specific messages.
func (*Error) Localized ¶
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 ¶
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 ¶
MarshalJSON implements json.Marshaler so json.Marshal(err) yields the wire form. Unsafe fields are omitted entirely (never serialized).
func (*Error) Opaque ¶
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) Public ¶
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 ¶
Remediation returns operator guidance: the Hint set on the error, else the remediation registered for its code.
func (*Error) RetryAfter ¶
func (*Error) SeverityOf ¶
func (*Error) Suppress ¶
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 (*Error) Trace ¶
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) URL ¶
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 ¶
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) WithDomain ¶
func (*Error) WithLocalized ¶
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) WithPublic ¶
func (*Error) WithRetryable ¶
WithRetryable marks the error retryable and, optionally, after how long.
func (*Error) WithSafe ¶
WithSafe attaches a field explicitly marked non-sensitive (never redacted).
func (*Error) WithSeverity ¶
func (*Error) WithSource ¶
WithSource attaches the source text that diagnostic labels index into (a query, a config file, a DSL snippet — whatever produced the error).
type Field ¶
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 Label ¶
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.
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.
type SamplePolicy ¶
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.
Source Files
¶
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. |