errors

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Aug 18, 2026 License: MIT Imports: 6 Imported by: 0

README

errors

Go Reference

Errors for the phpboyscout estate: stack traces, user-facing hints, structured attributes, and an aggregate that behaves.

It imports nothing. Not "few dependencies" — none, outside the standard library, asserted by a test rather than left to review. An error package is imported by every module in the estate, so it should be the lightest thing in the dependency graph rather than the heaviest.

go get gitlab.com/phpboyscout/go/errors

Use it

import "gitlab.com/phpboyscout/go/errors"

// A sentinel: no stack, and a stable kind so its identity survives a wire.
var ErrNotFound = errors.NewSentinel("forge.not_found", "provider not found")

func load(name string) error {
    if name == "" {
        return errors.WithHint(
            errors.WithStack(ErrNotFound),
            "Name a registered provider, or register one with forge.Register().",
        )
    }

    return errors.Wrapf(doLoad(name), "loading %s", name)
}
errors.Hints(err)          // what the user should DO about it
errors.Attrs(err)           // []slog.Attr, for a log record or a span
errors.StackOf(err)         // the outermost stack, renderable
errors.AsType[*MyErr](err)  // no out-parameter
fmt.Printf("%+v", err)      // message, hints, details, attributes, stack

Why not cockroachdb/errors

It was chosen for its features and for being well maintained. The features held; the second premise did not. It is maintained reactively for one consumer: a fix that broke CockroachDB's own build merged in five days, while a documentation typo has been open over two years.

The concrete cost was a defect it will not fix. errors.Join returns a withStack wrapping the aggregate, so every reporting traversal in the library treats a multi-error as a leaf — hints, details, context tags and telemetry keys all vanish below a Join, including when joining a single error. Their #162 reports it; the release that followed did not address it.

The estate used ten of its functions and paid 46 transitive packages for them, including a deprecated gogo/protobuf.

Their open backlog is, more or less, this package's design brief — zero dependencies, native stack traces, no Sentry, a standard-library-shaped Join. Several of those requests are over five years old.

Sentinels do not carry a stack

New captures a stack at the call site, which is right everywhere except the place it is used most: a package-level var. There the call site is package initialisation, so the stack points at runtime.doInit and the declaration — never anywhere the error was returned from.

So sentinels use NewSentinel, and become returnable errors with a useful stack at the point of return:

var ErrStale = errors.NewSentinel("config.stale", "configuration is stale")

return errors.WithStack(ErrStale)   // stack captured HERE

This matters more than it used to, because stacks are about to be rendered into log records and span attributes rather than merely printed on demand.

Structured logging

Errors implement slog.LogValuer, so an error reaches a log record as a group of attributes rather than a flattened string a query has to parse back:

logger.Error("release lookup failed", "err", err)
// err.msg=…  err.kind=forge.not_found  err.hint=…  err.host=codeberg.org

The stack is deliberately omitted from the log group — it is available through StackOf for a handler that wants it.

What lives elsewhere

Wire serialization arrives as a sibling module, keyed on ErrorKind. The core is shaped for it now — every layer has a stable kind, nothing type-switches on a concrete type, so a decoder meeting a kind it does not know can still carry the layer without breaking the chain.

OpenTelemetry belongs to go/observability, which already owns this estate's OTEL relationship. Deciding an error is worth recording happens where a span is in scope, not where the error is made — so this package ships no RecordError, only the contract one is built from.

Design

See spec 0001 on this project's wiki for the decisions and what was rejected, and the core spike report for what it cost to build and what it does not buy.

License

MIT — see LICENSE.

Documentation

Overview

Package errors is the phpboyscout estate's error package: stack traces, user-facing hints, structured attributes, and an aggregate that behaves.

It replaces github.com/cockroachdb/errors. See spec 0001 on this project's wiki for why, and for the decisions referenced throughout these files.

Nothing is imported

This package depends on the standard library and nothing else (D1), which depfootprint_test.go asserts. That is the property being bought: an error package is imported by every module in the estate, so it should be the lightest thing in the graph rather than the heaviest.

The API is deliberately familiar

Names and signatures match what the estate already called (D2), so migrating a module is an import-path change rather than a port.

Reading an error

Everything that inspects an error — hints, attributes, stacks, formatting, slog rendering — goes through one traversal that handles single- and multi-unwrap alike (D3). Nothing type-switches on this package's own concrete types, so a layer it has never seen travels the chain intact. That is what makes a future wire codec's opaque degradation possible (D6), and it is the defect that made the previous library unusable for aggregates.

What lives elsewhere

Wire serialization arrives as a sibling module keyed on ErrorKind (D6). OpenTelemetry belongs to gitlab.com/phpboyscout/go/observability, because deciding an error is worth recording happens where a span is in scope, not where the error is made (D7). This package ships no RecordError.

Index

Constants

View Source
const (
	KindBasic    = "errors.basic"
	KindSentinel = "errors.sentinel"
	KindMessage  = "errors.message"
	KindStack    = "errors.stack"
	KindHint     = "errors.hint"
	KindDetail   = "errors.detail"
	KindAttrs    = "errors.attrs"
	KindJoin     = "errors.join"
)

Kinds this package defines. Exported because a wire codec registers against them (spec 0001 D6) and telemetry reports them (D7).

Variables

This section is empty.

Functions

func As

func As(err error, target any) bool

As finds the first error in err's tree matching target's type.

Prefer AsType, which needs no out-parameter.

func AsType

func AsType[E error](err error) (E, bool)

AsType finds the first error in err's tree of type E.

Delegates to the standard library, which gained this in Go 1.26. Requested of cockroachdb/errors as their #156 and unanswered; it is strictly better than As with a pointer argument, so it is here (D2).

func Attrs

func Attrs(err error) []slog.Attr

Attrs returns every structured attribute in err's tree, outermost first.

Duplicate keys are kept in order: an outer layer adding context that an inner one already recorded is information, not noise, and which wins is the caller's decision.

func Details

func Details(err error) []string

Details returns every detail in err's tree, outermost first, de-duplicated.

func Errorf

func Errorf(format string, args ...any) error

Errorf is Newf. Both spellings are in use across the estate (D2).

func FlattenDetails

func FlattenDetails(err error) string

FlattenDetails concatenates every detail in err's tree.

func FlattenHints

func FlattenHints(err error) string

FlattenHints concatenates every hint in err's tree.

func GetAllDetails

func GetAllDetails(err error) []string

GetAllDetails is Details, under the name the estate already calls (D2).

func GetAllHints

func GetAllHints(err error) []string

GetAllHints is Hints, under the name the estate already calls (D2).

func Hints

func Hints(err error) []string

Hints returns every hint in err's tree, outermost first, de-duplicated.

func Is

func Is(err, target error) bool

Is reports whether any error in err's tree matches target.

func Join

func Join(errs ...error) error

Join aggregates its non-nil arguments, returning nil when there are none.

Hints, details, attributes and stacks attached to the joined errors stay readable through this package's readers, because walk descends into the aggregate. Nothing is copied upward to make that work — the traversal is simply correct, which is what makes it impossible to regress in one reader and not another.

func KindOf

func KindOf(err error) string

KindOf returns an error's identity: the outermost kind that is not an annotation wrapper — this package's own, or one declared through StructuralKinder.

This is what belongs in a log record and in OTEL's exception.type. The outermost kind alone would report whichever annotation happened to be applied last — "errors.attrs" for an error that had attributes attached — which describes this package's plumbing rather than the failure.

Returns "" when nothing in the tree declares a kind.

func New

func New(msg string) error

New returns an error with a stack trace captured at the call site.

For a PACKAGE-LEVEL SENTINEL, use NewSentinel instead — see D11. At package scope the call site is initialisation, so the stack New captures points at runtime.doInit and the var declaration rather than anywhere the error was returned from. That is invisible today and stops being invisible as soon as stacks reach logs (D10) and spans (D7).

func NewSentinel

func NewSentinel(kind, msg string) error

NewSentinel returns a package-level sentinel: no stack trace, and a stable kind so its identity can be re-established after crossing a process boundary.

Two requirements meet here, which is the reason for one constructor rather than two. A sentinel must not carry an initialisation-time stack (D11); and Is compares pointers, which do not survive a wire, so a future codec needs a stable key to map a decoded leaf back to this instance (D6).

The kind is a serialisation key — see Kinder. Namespace it:

var ErrNotFound = errors.NewSentinel("forge.not_found", "provider not found")

func Newf

func Newf(format string, args ...any) error

Newf returns a formatted error with a stack trace. %w is honoured, including more than once.

func Unwrap

func Unwrap(err error) error

Unwrap returns the result of err's Unwrap method, if any.

func WithAttrs

func WithAttrs(err error, attrs ...slog.Attr) error

WithAttrs attaches structured context to an error.

slog.Attr is used rather than a type of our own because both consumers want exactly this shape: a log record (D10) and an OTEL span attribute (D7) are both key/value. Flattening to a string here would only mean parsing it back there. log/slog is standard library, so this costs no dependency (D1).

return errors.WithAttrs(err, slog.String("host", host), slog.Int("attempt", n))

Deliberately NOT a mutable map handed back to the caller, which is how tozd/go/errors does it: mutating an error after construction invites aliasing and races.

func WithDetail

func WithDetail(err error, detail string) error

WithDetail attaches operator-facing detail — prose for a person.

For anything a machine will query, use WithAttrs instead (D12).

func WithDetailf

func WithDetailf(err error, format string, args ...any) error

WithDetailf attaches formatted operator-facing detail.

func WithHint

func WithHint(err error, hint string) error

WithHint attaches a user-facing hint. Returns nil when err is nil.

func WithHintf

func WithHintf(err error, format string, args ...any) error

WithHintf attaches a formatted user-facing hint.

func WithStack

func WithStack(err error) error

WithStack annotates err with a call stack, unless the tree already carries one. Returns nil when err is nil.

This is how a sentinel from NewSentinel becomes a returnable error with a useful stack: the stack is captured where the error is RETURNED rather than where it was declared (D11).

return errors.WithStack(ErrNotFound)

func Wrap

func Wrap(err error, msg string) error

Wrap annotates err with a message and a stack. Returns nil when err is nil, so it composes in a return statement without a guard.

func Wrapf

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

Wrapf annotates err with a formatted message and a stack.

Types

type Kinder

type Kinder interface {
	ErrorKind() string
}

Kinder gives a layer a stable identity, used to route it — to a wire codec, to a telemetry attribute.

The string is a SERIALISATION KEY. Once a kind has crossed a process boundary, changing it breaks decoders that have not been updated, so treat these as you would a protobuf field number.

type Payloader

type Payloader interface {
	ErrorKind() string
	ErrorPayload() any
}

Payloader exposes what a layer carries, for anything that needs to move or record it rather than print it.

The concrete type is per-kind and documented on each wrapper. A reader that does not recognise a kind must ignore the payload rather than assume a shape.

type StackTrace

type StackTrace []uintptr

StackTrace is a captured call stack.

Exported, and renderable, because go/observability needs it for OTEL's exception.stacktrace (D7). A private field a formatter happened to reach would not serve that.

func DistinctStacks added in v0.2.0

func DistinctStacks(err error) []StackTrace

DistinctStacks returns the stacks in err's tree with the redundant ones dropped, outermost first.

Every capture happens at its own call site, so on a straight call path each outer stack is a strict suffix of the one captured deeper: wrapping three times in a row yields three stacks describing the same descent, and rendering all three repeats the same frames three times. Only the deepest carries anything the others do not.

A stack survives when it is not wholly contained in a deeper one, which is exactly when it says something new. Two cases produce that:

  • an error created on one call path and wrapped from another, after being returned or stored
  • an error wrapped in a different goroutine, where the stacks are disjoint

So the common case collapses to a single stack, the origin, and a genuinely divergent history keeps every branch of it.

func OriginStack added in v0.2.0

func OriginStack(err error) StackTrace

OriginStack returns the innermost stack trace in err's tree, or nil.

That is the one captured closest to where the error was created, which is what a debugger usually wants and what StackOf deliberately does not give. A wrapped error reports where it surfaced; this reports where it began.

func StackOf

func StackOf(err error) StackTrace

StackOf returns the outermost stack trace in err's tree, or nil.

The outermost is the most recently captured, and so the closest to where the error surfaced — which is the one worth reporting. It is also why a sentinel from NewSentinel carrying none is harmless: WithStack at the return site supplies the useful one (D11).

func Stacks added in v0.2.0

func Stacks(err error) []StackTrace

Stacks returns every stack trace in err's tree, outermost first.

StackOf answers "where did this surface", which is the right question for a log line and is why it stops at the first stack it finds. This answers "how did it get here", which is the question a person debugging asks: each Wrap captures a stack at its own call site, so a chain wrapped three deep carries three, and the last one returned is the closest to where the error was made.

Nothing new is captured. The stacks were always in the tree; this reads all of them rather than the first.

The order matches the traversal, so it reads outward-in the same way the message chain does: "loading configuration: opening /etc/gtb: permission denied" pairs with surface, middle, origin.

func (StackTrace) String

func (s StackTrace) String() string

String renders the conventional Go form — alternating function line and tab-indented file:line — which is what OTEL's exception.stacktrace expects and what a reader of a panic already knows how to scan.

type StackTracer

type StackTracer interface {
	StackTrace() StackTrace
}

StackTracer is implemented by any layer carrying a stack trace.

Separate from Kinder/Payloader on purpose: those route a layer to a codec or an attribute by kind, whereas a stack is a capability several kinds have — a leaf from New carries one, and so does a WithStack wrapper, though they are different kinds with different payloads.

Implementing it is how a consumer's own error type, or a decoded wire layer, contributes a stack to StackOf (D3 rule 2).

type StructuralKinder added in v0.3.0

type StructuralKinder interface {
	Kinder

	StructuralKind() bool
}

StructuralKinder lets a wrapper OUTSIDE this package declare that its kind describes how an error was annotated rather than what the error is, so that KindOf looks past it.

This package skips its own annotations already — a hint or a stack says how an error was annotated; a sentinel's kind says what it IS. Other packages wrap errors for the same reason and had no way to say so: go/errorhandling attaches an Outcome and an exit code, and their kinds were masking the identity of every error they touched.

Answering false is meaningful and is not the same as not implementing the interface at all: it says this kind IS an identity, deliberately.

func (w *withOutcome) ErrorKind() string    { return KindOutcome }
func (w *withOutcome) StructuralKind() bool { return true }

Jump to

Keyboard shortcuts

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