errs

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jul 25, 2026 License: MIT Imports: 5 Imported by: 0

README

errs

Structured errors for Go services: every wrap keeps its own message and call site, the origin gets a full stack trace, and each error can carry a machine-readable code, a client-safe public message, an HTTP status, and allowlisted metadata. Standard library only, zero dependencies.

errs is developed as part of the gp-system tooling but has no dependency on it: it works in any Go program.

The problem it solves

Idiomatic Go error handling composes errors with fmt.Errorf("op: %w", err). That convention has three gaps once errors end up in logs and HTTP responses:

  1. The chain collapses into one string. By the time an error reaches your logger it reads "api: create user: repo: insert: connection refused". You can no longer tell which layer added which part, and there is no stack trace showing where the failure actually started.
  2. One message serves two audiences. The internal message is written for operators (table names, query details, upstream hosts). Returning it to an HTTP client leaks internals; writing it for clients starves your logs.
  3. No stable machine handle. Alerting, Sentry grouping, and API error contracts need a code that survives message rewording. A string prefix is not that.

errs closes these gaps while staying byte-compatible with the stdlib convention: Error() output is identical to the usual "pkg: op: %w" composition, and errors.Is / errors.As / errors.Join work unchanged. You can adopt it incrementally; callers that treat errors as plain values notice no difference.

Install

go get github.com/gp-system/errs

Usage

Creating and wrapping
import "github.com/gp-system/errs"

// New captures the call site and a full stack trace.
return errs.New("cache: warmup timed out")

// Errorf works like fmt.Errorf; %w (single and multi) is honored.
return errs.Errorf("parse config %q: %w", path, err)

// Wrap annotates a cause with this level's message and call site.
// It returns nil when err is nil, so it is safe in one-line returns.
return errs.Wrap(err, "news: publish")

// Wrapf is Wrap with a Sprintf message.
return errs.Wrapf(err, "news: publish id=%d", id)

A stack trace is captured once, at the deepest errs error in a chain. Outer wraps only record their own call site (one frame each), so wrapping stays cheap and logs show both the origin stack and the path the error took up through the layers.

Attributes

Attach optional context at construction time:

return errs.Wrap(err, "contact: insert",
    errs.Code("contact_create_failed"),          // stable machine-readable code
    errs.Public("Saving failed, try again."),    // the ONLY text a client may see
    errs.Status(http.StatusServiceUnavailable),  // HTTP status to map to
    errs.With("contact_id", id),                 // allowlisted metadata, scalars only
)
  • Code is a snake_case identifier, stable across releases. Convention: <module>_<action>_failed for unexpected operational failures, <module>_<condition> for expected business errors (contact_not_found).
  • Public is the client-safe message. The internal message from Error() is never meant to reach a client.
  • With takes scalars, not whole structs: explicit fields keep sensitive data out of logs.

Read them back from anywhere in a chain (outermost value wins):

errs.CodeOf(err)   // "" if unset
errs.PublicOf(err) // "" if unset
errs.StatusOf(err) // 0 if unset
Definitions: sentinels with context

Package-level sentinels should stay errors.New: an errs.New sentinel would freeze an init-time stack and share mutable attributes across requests. When a sentinel should also carry a code, public message, or status, declare a Definition instead:

var ErrNotFound = errs.Define("news_not_found",
    errs.Public("The article does not exist."),
    errs.Status(http.StatusNotFound))

A Definition is immutable and stackless, so it is safe as a package-level var. Instantiate it at the failure site; each occurrence captures its own call site and stack:

return ErrNotFound.Wrap(err, "news: get by slug")
// or, without a cause:
return ErrNotFound.Newf("news: no article with slug %q", slug)

errors.Is(err, ErrNotFound) matches any error instantiated from the definition, at any depth of further wrapping. Per-call attributes override the definition's defaults for that occurrence. Define panics at init time if the status is not a 4xx or 5xx code.

Logging

*errs.Error implements slog.LogValuer. Logging the error as a value expands it into a structured group with the message, code, public message, the step-by-step chain (each level's message, call site, code, and metadata), and the origin stack trace:

slog.Error("request failed", "error", err)

For panic recovery, errs.NewPanic(recover()) builds an error whose stack still contains the panic site, and preserves an error panic value as the cause so errors.Is / errors.As keep working.

Inspecting programmatically
errs.Frames(err)     // []Frame: origin stack, file paths shortened
errs.FullFrames(err) // []Frame: origin stack, absolute file paths
errs.Chain(err)      // []Step: one entry per wrap level, outermost first

*errs.Error also exposes StackTrace() []uintptr in the shape recognized by sentry-go, so Sentry reports get real stack traces without extra glue.

Design rules

  • Two audiences, two messages. Error() is for logs and operators; Public is for clients. Nothing chooses between them implicitly: whatever renders your HTTP responses should send PublicOf(err) and log the rest.
  • Wrap at every layer boundary, with a short "pkg: op" annotation. The chain then reads as a path through the codebase.
  • Codes are API. Treat renaming a code like a breaking change.
  • Plain sentinels stay errors.New, wrapped per call site (errs.Wrap(ErrNoRows, "user: get")). Reach for Define only when the sentinel needs a code, public message, or status.

Documentation

Overview

Package errs provides error values that carry structured context for logs: the location of every wrap, a full stack trace from the point of origin, a machine-readable code, a client-safe public message, and allowlisted metadata.

Errors compose like fmt.Errorf — Error() output is byte-identical to the usual "pkg: op: %w" convention and errors.Is/As/Join work unchanged — but each wrap level keeps its own message and call site, so logs can render the chain step by step instead of one concatenated string.

The internal message (Error()) is never meant for clients. Attach Public to set what a client may see; httperr renders it as the Problem detail. Stack traces and chains appear in logs via LogValue; httperr also includes them in responses, but only in dev mode (WithExposeInternal).

Package-level sentinels should stay stdlib errors.New: an errs.New sentinel would freeze an init-time stack and share mutable attrs across requests. Wrap the sentinel per call instead: errs.Wrap(ErrNotFound, "news: get"). For a sentinel that also carries a code, a public message or an HTTP status, declare an errs.Definition instead (see define.go): definitions are immutable and stackless, so they are safe as package-level vars, and every occurrence still gets its own stack captured at the call site via Definition.New/Wrap.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func CodeOf

func CodeOf(err error) string

CodeOf returns the first code found walking err's chain from the outside in, or "" if none is set. A bare *Definition (returned instead of an instantiated error by mistake) is also recognized, so misuse still surfaces a code rather than none.

func Errorf

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

Errorf builds a new error fmt.Errorf-style; %w (single and multi) works as usual. The stack is captured here unless a wrapped cause already has one.

func New

func New(msg string, attrs ...Attr) error

New builds a new error, capturing the call site and a full stack trace.

func NewPanic

func NewPanic(rec any, attrs ...Attr) error

NewPanic builds an error from a recovered panic value, capturing the stack at the recovery point. Called inside a deferred function during unwinding, the captured stack still contains the panic site below runtime.gopanic. An error panic value becomes the cause, so errors.Is/As keep working; any other value is formatted as "panic: %v". It must call newError directly so the callerSkip depth stays the same as New (pinned by TestNewPanicCapturesPanicSite).

func PublicOf

func PublicOf(err error) string

PublicOf returns the first client-safe message found walking err's chain from the outside in, or "" if none is set.

func StatusOf

func StatusOf(err error) int

StatusOf returns the first HTTP status found walking err's chain from the outside in, or 0 if none is set. httperr.mapProblem falls back to 500.

func Wrap

func Wrap(err error, msg string, attrs ...Attr) error

Wrap annotates err with a message and the call site. It returns nil when err is nil, so it is safe in one-line returns. A full stack trace is captured only if no error in err's chain carries one already.

func Wrapf

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

Wrapf is Wrap with a fmt.Sprintf message. The cause is the explicit err argument; %w is not interpreted here.

Types

type Attr

type Attr func(*Error)

Attr attaches optional context to an error at construction time.

func Code

func Code(code string) Attr

Code sets a machine-readable error code, e.g. "contact_create_failed": snake_case, stable across releases. It becomes the Sentry issue title and grouping fingerprint (see sentryx), the "errs.code" log/tag field and the RFC 9457 "code" member of the HTTP response. Convention: "<module>_<action>_failed" for unexpected operational failures (e.g. "contact_create_failed"), "<module>_<condition>" for expected business errors (e.g. "contact_not_found").

func Public

func Public(msg string) Attr

Public sets the client-safe message. It is the only text from the error that may reach an HTTP response, and is shown as the Sentry issue's secondary line (see sentryx) in place of the internal composed message.

func Status

func Status(status int) Attr

Status sets the HTTP status this error should map to (a 4xx or 5xx code). httperr.mapProblem uses it via StatusOf, falling back to 500 when unset.

func With

func With(key string, value any) Attr

With attaches one allowlisted metadata key/value pair. Pass scalars, not whole structs — explicit fields keep sensitive data out of logs.

type Definition

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

Definition is a declared error kind: a stable code, a default client-safe message and an optional HTTP status, shared by every occurrence of one failure mode. Declare one per domain error at package level —

var ErrSubmit = errs.Define("contact_submit_failed",
	errs.Public("Sikertelen mentés, próbálja újra később."))

— and instantiate it at the failure site with New/Newf/Wrap/Wrapf, which capture the call site and stack exactly like the package-level counterparts:

return ErrSubmit.Wrap(err, "contact: insert")

A Definition itself carries no stack and no mutable state, so unlike an errs.New sentinel it is safe as a package-level var (see the package doc). errors.Is(err, ErrSubmit) matches any error instantiated from it, at any depth of further wrapping, via (*Error).Is.

func Define

func Define(code string, attrs ...Attr) *Definition

Define declares an error kind. code becomes the Sentry issue title and grouping fingerprint, the "errs.code" tag, and the RFC 9457 "code" member (see the Code attr doc for the naming convention). attrs accepts Public, Status and With to set the definition's defaults; Code is redundant here (code is already the first argument) and Status must be a 4xx or 5xx HTTP status or Define panics — both are programmer errors caught at init time, not request time.

func (*Definition) Error

func (d *Definition) Error() string

Error returns the definition's code, so a *Definition is itself a legal errors.Is target (and, if ever returned directly by mistake, still produces a meaningful message instead of a blank one). Never return a Definition itself as an error — always instantiate with New/Newf/Wrap/Wrapf so a stack is captured at the call site.

func (*Definition) New

func (d *Definition) New(msg string, attrs ...Attr) error

New builds a new error of this kind, capturing the call site and a full stack trace. attrs are applied after the definition's defaults, so they can override code/public/status/meta for this occurrence.

It calls newError directly (like NewPanic) so callerSkip stays pinned to the same depth as the package-level constructors; see TestDefinitionCaptureSkip.

func (*Definition) Newf

func (d *Definition) Newf(format string, args ...any) error

Newf is New with a fmt.Sprintf message.

func (*Definition) Wrap

func (d *Definition) Wrap(err error, msg string, attrs ...Attr) error

Wrap annotates err with a message and the call site, stamped with this definition. It returns nil when err is nil, so it is safe in one-line returns. A full stack trace is captured only if no error in err's chain carries one already.

func (*Definition) Wrapf

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

Wrapf is Wrap with a fmt.Sprintf message.

type Error

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

Error is the concrete error type. It is exported only so errors.As works; construct values with New, Errorf, Wrap or Wrapf.

func (*Error) Error

func (e *Error) Error() string

func (*Error) Is

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

Is reports whether e was instantiated from the given Definition, so errors.Is(err, someDefinition) matches any occurrence stamped from it (Definition.New/.Wrap), no matter how deeply it is later wrapped. It returns false for any other target, leaving errors.Is's default identity comparison (and any other Is method in the chain) untouched.

func (*Error) LogValue

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

LogValue renders the error as a structured group so slog.Any("error", err) emits per-level detail instead of one flattened string:

"error": {
  "msg":    "news: publish: news article: get: connection refused",
  "code":   "article_get",
  "public": "...",
  "chain":  [{"msg","file","line","function","code","meta"}, ...],
  "stack":  [{"file","line","function"}, ...]
}

chain lists every wrap level with its own message, call site and attrs; stack is the full call path from the point of origin. Stacks resolve lazily — the cost is paid only when the error is actually logged.

func (*Error) StackTrace

func (e *Error) StackTrace() []uintptr

StackTrace returns the program counters of the chain's full stack. The method shape is recognized by sentry-go's reflected stack extraction.

func (*Error) Unwrap

func (e *Error) Unwrap() error

type Frame

type Frame struct {
	File     string `json:"file"` // last two path segments, e.g. "pg/transactor.go"
	Line     int    `json:"line"`
	Function string `json:"function"` // without module path, e.g. "pg.(*Transactor).WithinTx"
}

Frame is one resolved stack frame, structured for log pipelines. Frames shortens File to the last two path segments; FullFrames keeps the absolute path for console renderers that want exact locations.

func Frames

func Frames(err error) []Frame

Frames returns the full stack from the error's point of origin, resolved lazily, with File shortened to the last two path segments. It returns nil when no error in the chain carries a stack.

func FullFrames

func FullFrames(err error) []Frame

FullFrames is Frames with untruncated absolute file paths. Console renderers that print clickable locations (otelx LOG_FORMAT=monolog) use it; most log pipelines want the shorter Frames.

type Step

type Step struct {
	Msg      string         `json:"msg"`
	File     string         `json:"file,omitempty"`
	Line     int            `json:"line,omitempty"`
	Function string         `json:"function,omitempty"`
	Code     string         `json:"code,omitempty"`
	Meta     map[string]any `json:"meta,omitempty"`
}

Step is one level of an error chain: the level's own message, the call site of the wrap, and the attrs set at that level. The root cause appears as a final Step without location.

func Chain

func Chain(err error) []Step

Chain returns the error chain as structured steps, outermost first. Intermediate fmt.Errorf wrappers appear as message-only steps; joined errors contribute their branches depth-first.

Jump to

Keyboard shortcuts

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