Skip to content

Repository files navigation

Gravitton errors

Latest Stable Version Build Status Coverage Status Go Dev Reference Software License

Structured errors with fields, causes, and stack traces, plus a concurrent-safe multi error


Features

  • Drop-in replacement for the standard errors package – swap the import, keep New, Is, As and AsType.
  • Stack traces captured where the error is raised and printed with %+v.
  • Fields – key-value context attached to an error.
  • Cause – the error that caused this one, visible to Is and As.
  • Identity – every copy made by With* is still the error it came from for errors.Is.
  • Immutable – every With* method returns a new error.
  • Structured logging – errors are logged by slog as groups of their message, fields and cause.
  • Multi error – concurrent-safe collection of errors as a single error, with Join on top.

Installation

go get github.com/gravitton/errors

Usage

- "errors"
+ "github.com/gravitton/errors"

Creating

var ErrNotFound = errors.Sentinel("not found") // a plain error without a stack trace

err := errors.New("boom")                      // *Error with the current stack trace
err = errors.Newf("user %d: %w", 42, ErrNotFound)
err = errors.Wrap(io.EOF)                      // *Error around io.EOF
err = errors.Wrap(err)                         // an *Error is returned unchanged

Use Sentinel, not New, for package-level errors: at package initialization, New captures a stack trace that points nowhere useful. Wrap the sentinel where it is returned, which captures the stack trace there.

Check the error before wrapping it, never after: Wrap(nil) returns a nil *Error, which is not nil once stored in an error. Reading a nil *Error or *MultiError is safe and prints <nil>, but With* and Add panic on one. Wrap returns nil for one, and Add, Join and WithCause skip one, so it never ends up in a collection or as a cause.

if err := load(); err != nil {
	return errors.Wrap(err).WithField("file", name)
}

return nil

Fields

err := errors.Wrap(ErrNotFound).WithField("id", 42)
err = err.WithFields(map[string]any{"table": "users"})

err.Fields()                // map[id:42 table:users]
errors.Is(err, ErrNotFound) // true

Fields don't change identity: a copy made by With* is still the error it came from.

err := errors.New("timeout")
errors.Is(err.WithField("attempt", 3), err) // true

Match a sentinel by the sentinel itself, not by a Wrap of it: an *Error target is only matched by an *Error in the tree that wraps the sentinel or has it as a cause, never by a plain wrapper around the sentinel.

err := fmt.Errorf("load: %w", ErrNotFound)
errors.Is(err, ErrNotFound)              // true
errors.Is(err, errors.Wrap(ErrNotFound)) // false

Cause

The error that caused this one, or one that happened while handling it. Another cause is joined with the ones already attached into one flat MultiError.

if err := load(); err != nil {
	return errors.New("could not load config").WithCause(err)
}
if err := write(f); err != nil {
	if closeErr := f.Close(); closeErr != nil {
		return errors.Wrap(err).WithCause(closeErr)
	}

	return err
}

A collection attaches a copy of its members, with nested collections flattened, and an empty one attaches nothing, so a collection can be attached as it is:

return errors.New("could not process batch").WithCause(errs)

Wrapping

Wrapping an *Error, with Wrap or with Newf and %w, reuses its stack trace, which is closer to where the error was raised. Only the Unwrap() error chain is searched: a collection, such as Join, gets the current stack trace. The fields and cause of the wrapped error stay on it, reachable with errors.As:

outer := errors.Newf("load user: %w", err)

outer.StackTrace()            // the stack trace of err
outer.Fields()                // map[]
errors.Is(outer, ErrNotFound) // true

Collecting

A MultiError collects errors into a single error. It is safe for concurrent use, so goroutines can add to one collection without extra locking.

errs := errors.NewMulti()
errs.Add(process(1), process(2)) // nils, nil *Errors and nil *MultiErrors are skipped

return errs.ErrorOrNil()         // nil when nothing was added

An empty collection is kept when added, since errors may be added to it later, so the collection holding it is not empty even while it stays so:

child := errors.NewMulti()
errs.Add(child)

errs.ErrorOrNil() // not nil, with an empty message
errs := errors.NewMulti()
wg := sync.WaitGroup{}

for i := range 10 {
	wg.Go(func() {
		if err := process(i); err != nil {
			errs.Add(errors.Wrap(err).WithField("process", i))
		}
	})
}

wg.Wait()

return errs.ErrorOrNil()
errors.Join(nil, nil)   // nil
errors.Join(errA, errB) // *MultiError, "errA\nerrB" as the standard errors.Join

Printing

%v prints the message, %+v adds the fields, the stack trace and the cause, and %#v prints Go syntax without the stack trace:

fmt.Printf("%+v", err)
// process failed
//	process=abc
//	main.Process
//		/app/main.go:14
//	caused by: could not dial
//		main.dial
//			/app/main.go:27
//		caused by: connection refused

%+v applies to the underlying error and to every cause, so their details are printed too. An *Error wrapped with %w is the exception: fmt prints only the wrapper's message, so its fields and cause are left out, reachable with errors.As. A MultiError joins its members with newlines, as errors.Join does, with %+v applied to every member. Wrapping a MultiError in an *Error prints its members first, so the fields and stack trace of the *Error follow the last member at the same indentation.

Logging

logger.Error("load failed", "err", err)
// level=ERROR msg="load failed" err.msg="not found" err.fields.id=42 err.fields.table=users

logger.Error("batch failed", "err", errors.Join(err, io.EOF))
// level=ERROR msg="batch failed" err.0.msg="not found" err.0.fields.id=42 err.0.fields.table=users err.1=EOF

The stack trace is left out of logs; it belongs to %+v and error reporting.

Error reporting

StackTrace() []uintptr is the method Sentry looks for, so it picks up the stack trace; Frames() resolves it into runtime.Frame values for other reporters. A reporter should walk the whole tree with Unwrap to collect the fields of every *Error in it, and skip a stack trace equal to the one before, since wrapping errors share it.

A slog handler reporting errors gets the *Error from Value.Any() before resolving the value; once resolved, it is the LogValue group. Handlers that resolve first, such as Sentry's, and ReplaceAttr hooks of the built-in handlers find no error, so report the error where it is handled instead.

Full reference: pkg.go.dev.

Differences from the standard library

  • errors.Unwrap returns nil for every *Error, as it does for errors.Join: it only follows Unwrap() error, and *Error implements Unwrap() []error to expose both its underlying error and its cause. Use Is and As, or walk Unwrap() []error.
  • New returns *Error, so err := errors.New("x") declares an *Error that can't be assigned a plain error later.
  • Package-level New errors report package initialization as their stack trace; turn them into Sentinels.
  • Join returns a *MultiError, with the same message as the standard one.

Credits

License

The MIT License (MIT). Please see License File for more information.

About

Concurrent safe representation of a list of errors as a single error.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Contributors

Languages