errors

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Jul 7, 2024 License: MIT Imports: 8 Imported by: 4

README

Package errors

errors makes go error handling more powerful and expressive while working alongside the std lib errors. This pkg is largely inspired by upspin project's error handling and github.com/pkg/errors. It also adds a few lessons learned from creating a module like this a number of times across numerous projects.

Familiar favorites at your disposal

This pkg is a drop in replace for github.com/pkg/errors, with a nearly identical interface. Similarly, the std lib errors module's functionality have been replicated in this pkg so that you only ever have to work from a single errors module. Here are some examples of what you can do:

package foo

import (
	"github.com/jsteenb2/errors"
)

func Simple() error {
	return errors.New("simple error")
}

func Enriched() error {
	return errors.New("enriched error", errors.KVs("key_1", "some val", "power_level", 9000))
}

func ErrKindInvalid() error {
	return errors.New("invalid kind error", errors.Kind("invalid"))
}

func Wrapped() error {
	// note if errors.Wrap is passed a nil error, then it returns a nil.
	// matching the behavior of github.com/pkg/errors
	return errors.Wrap(Simple())
}

func Unwrapped() error {
	// no need to import multiple errors pkgs to get the std lib behavior. The
	// small API surface area for the std lib errors are available from this module.
	return errors.Unwrap(Wrapped()) // returns simple error again
}

func WrapFields() error {
	// Add an error Kind and some additional KV metadata. Enrich those errors, and better
	// inform the oncall you that might wake up at 03:00 in the morning :upside_face:
	return errors.Wrap(Enriched(), errors.Kind("some_err_kind"), errors.KVs("dodgers", "stink"))
}

func Joined() error {
	// defaults to printing joined/multi errors as hashicorp's go-multierr does. The std libs,
	// formatter can also be provided.
	return errors.Join(Simple(), Enriched(), ErrKindInvalid())
}

func Disjoined() []error {
	// splits up the Joined errors back to their indivisible parts []error{Simple, Enriched, ErrKindInvalid}
	return errors.Disjoin(Joined())
}

This is a quick example of what's available. The std lib errors, github.com/pkg/errors, hashicorp's go-multierr, and the upspin projects error handling all bring incredible examples of error handling. However, they all have their limitations.

The std lib errors are intensely simple. Great for a hot path, but not great for creating structured/enriched errors that are useful when creating services and beyond.

The github.com/pkg/errors laid the ground work for what is the std lib errors today, but also provided access to a callstack for the errors. This module takes a similar approach to github.com/pkg/errors's callstack capture, except that it is not capturing the entire stack all the time. We'll touch on this more soon.

Now with the go-multierr module, we have excellent ways to combine errors into a single return type that satisfies the error interface. However, similar to the std lib, that's about all it does. You can use Is/As with it, which is great, but it does not provide any means to add additional context or behavior.

The best in show (imo, YMMV) for error modules is the upspin project's error handling. The obvious downside to it, is its specific to upspin. For many applications creating this whole error handling setup wholesale, can be daunting as the upspin project did an amazing job of writing their error pkg to suit their needs.

Error behavior untangles the error handling hairball

Instead of focusing on a multitude of specific error types or worse, a gigantic list of sentinel errors, one for each individual "thing", you can category your errors with errors.Kind. The following is an error that exhibits a not_found behavior.

// domain.go

package foo

import (
	"github.com/jsteenb2/errors"
)

const (
	ErrKindInvalid  = errors.Kind("invalid")
	ErrKindNotFound = errors.Kind("not_found")
	// ... additional
)

func FooDo() {
	err := complexDoer()
	if errors.Is(ErrKindNotFound, err) {
		// handle not found error
	}
}

func complexDoer() error {
	// ... trim
	return errors.New("some not found error", ErrKindNotFound)
}

This works across any number of functional boundaries. Instead of creating a new type that just holds info like a NotFound method or a field for behavior, we can utilize the errors.Kind to decorate our error handling. Imagine a situation you've probably been in before, a service that has N entities, and error handling that sprawls with the use of one off error types or worse, a bazillion sentinel errors. This makes it difficult to abstract across. By categorizing errors, you can create strong abstractions. Take the http layer, where we often want to correlate an error to a specific HTTP Status code. With the kinds above we can do something like:

package foo

import (
	"net/http"
	
	"github.com/jsteenb2/errors"
)

func errHTTPStatus(err error) int {
	switch {
	case errors.Is(ErrKindInvalid, err):
		return http.StatusBadRequest
	case errors.Is(ErrKindNotFound, err):
		return http.StatusNotFound
	default:
		return http.StatusInternalServerError
	}
}

Pretty neat yah?

Adding metadata/fields to contextualize the error

One of the strongest cases I can make for this module is the use of the errors.Fields function we provide. Each error created, wrapped or joined, can have additional metadata added to the error to contextualize the error. Instead of wrapping the error using fmt.Errorf("new context str: %w", err), you can use intelligent error handling, and leave the message as refined as you like. Its simpler to just see the code in action:

package foo

import (
	"github.com/jsteenb2/errors"
)

func Up(timeline string, powerLvl, teamSize int) error {
	return errors.New("the up failed", errors.Kind("went_ape"), errors.KVs(
		"timeline", timeline,
		"power_level", powerLvl,
		"team_size", teamSize,
		"rando_other_field", "dodgers stink",
	))
}

func DownUp(timeline string, powerLvl, teamSize int) error {
	// ... trim 
	err := Up(timeline, powerLvl, teamSize)
	return errors.Wrap(err)
}

Here we are returning an error from the Up function, that has some context added (via errors.KVs and errors.Kind). Additionally, we get a stack trace added as well. Now lets see what these fields actually look like:

package foo

import (
	"fmt"
	
	"github.com/jsteenb2/errors"
)

func do() {
	err := DownUp("dbz", 9009, 4)
	if err != nil {
		fmt.Printf("%#v\n", errors.Fields(err))
		/*
		    Outputs: []any{
		        "timeline", "dbz",
		        "power_level", 9009,
		        "team_size", 4,
		        "rando_other_field", "dodgers stink",
		        "err_kind", "went_ape",
		        "stack_trace", []string{
		            "github.com/jsteenb2/README.go:26[DownUp]",  // the wrapping point
		            "github.com/jsteenb2/README.go:15[Up]", // the new error call
		        },
		    }
		
		   Note: the filename in hte stack trace is made up for this read me doc. In
		         reality, it'll show the file of the call to errors.{New|Wrap|Join}.
		*/
	}
}

The above output, is golden for informing logging infrastructure. Becomes very simple to create as much context as possible to debug an error. It becomes very easy to follow the advice of John Carmack, by adding assertions, or good error handling in go's case, without having to drop a blender on the actual error message. When that error message remains clean, it empowers your observability stack. Reducing the cardinality and being able to see across the different facets your fields provide can create opportunities to explore relationships between failures. Additionally, there's a fair chance that a bunch of DEBUG logs can be removed. Your SRE/infra teams will thank for it :-).

Limitations

Worth noting here, this pkg has some limitations, like in hot loops. In that case, you may want to use std lib errors or similar for the hot loop, then return the result of those with this module's error handling with a simple:

errors.Wrap(hotPathErr)

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func As

func As(err error, target any) bool

As is a direct callout to the std lib errors.As function. This allows users to only ever have to worry about including one errors pkg.

func Disjoin

func Disjoin(err error) []error

Disjoin separates joined errors.

func Fields

func Fields(err error) []any

Fields returns logging fields for a given error.

func Is

func Is(err, target error) bool

Is is a direct callout to the std lib errors.Is function. This allows users to only ever have to worry about including one errors pkg.

func Join

func Join(opts ...any) error

Join returns a new multi error.

TODO:

  • play with Join(opts ...any) and Join(errs []error, opts ...any) sigs and ask for feedback regarding tradeoffs with type safety of first arg. As of writing some tests, I kind of dig the loose Join(opts ...any).

func New

func New(msg string, opts ...any) error

New creates a new error.

func Unwrap

func Unwrap(err error) error

Unwrap is a direct callout to the std lib errors.Unwrap function. This allows users to only ever have to worry about including one errors pkg. The use of the std lib errors.Unwrap is a now thing, but may change in future releases.

func V

func V(err error, key string) any

V returns a typed value for the kvs of an error. Type conversion can be used to convert the output value. We do not distinguish between a purposeful <nil> value and key not found. With the single return param, we can do the following to convert it to a more specific type:

err := errors.New("simple msg", errors.KVs("int", 1))
i, ok := errors.V(err, "int).(int)

Note: this will take the first matching key. If you are interested in obtaining a key's value from a wrapped error collides with a parent's key value, then you can manually unwrap the error and call V on it to skip the parent field.

TODO:

  • food for thought, we could change the V funcs signature to allow for a generic type to be provided, however... it feels both premature and limiting in the event you don't care about the type. If we get an ask for that, we can provide guidance for this via the comment above and perhaps some example code.

func Wrap

func Wrap(err error, opts ...any) error

Wrap wraps the provided error and includes any additional options on this entry of the error. Note, a msg is not required. A new stack frame will be captured when calling Wrap. It is useful for that alone. This function will not wrap a nil error, rather, it'll return with a nil.

Types

type Frame

type Frame struct {
	FilePath string
	Fn       string
	Line     int
}

Frame is a single step in stack trace.

func (Frame) Format

func (f Frame) Format(s fmt.State, verb rune)

Format formats the frame according to the fmt.Formatter interface.

%s    source file
%d    source line
%n    function name
%v    equivalent to %s:%d

Format accepts flags that alter the printing of some verbs, as follows:

%+s   function name and path of source file relative to the compile time
      GOPATH separated by \n\t (<funcname>\n\t<path>)
%+v   equivalent to %+s:%d

func (Frame) String

func (f Frame) String() string

String formats Frame to string.

type FrameSkips

type FrameSkips int

FrameSkips marks the number of frames to skip in collecting the stack frame. This is helpful when creating helper functions. TODO(berg): give example of helper functions here

const (
	// NoFrame marks the error to not have an error frame captured. This is useful
	// when the stack frame is of no use to you the consumer.
	NoFrame FrameSkips = -1

	// SkipCaller skips the immediate caller of the functions. Useful for creating
	// reusable Error constructors in consumer code.
	SkipCaller FrameSkips = 1
)

type JoinFormatFn

type JoinFormatFn func(msg string, errs []error) string

JoinFormatFn is the join errors formatter. This allows the user to customize the text output when calling Error() on the join error.

type KV

type KV struct {
	K string
	V any
}

KV provides context to the error. These can be triggered by different formatter options with fmt.*printf calls of the error. TODO:

  1. explore other data structures for passing in the key val pairs

func KVs

func KVs(fields ...any) []KV

KVs takes a slice of argument kv pairs, where the first of each pair must be the key string, and the latter the value. Additionally, a key that is a type that implements the strings.Stringer interface is also accepted.

TODO:

  • I really like the ergonomics of this when working with errors, quite handy to replace the exhaustion of working with the KV type above. similar approach maybe useful for other sorts of metadata as well.

type Kind

type Kind string

Kind represents the category of the error type. A few examples of error kinds are as follows:

const (
	// represents an error for a entity/thing that was not found
	errKindNotFound = errors.Kind("not found")

	//represents an error for a validation error
	errKindInvalid = errors.Kind("invalid")
)

With the kind, you can write common error handling across the error's Kind. This can create a dramatic improvement abstracting errors, allowing the behavior (kind) of an error to dictate semantics instead of having to rely on N sentinel or custom error types.

Additionally, the Kind can be used to assert that an error is of a kind. The following uses the std lib errors.Is to determine if the target error is of kind "first":

err := errors.New("some error", errors.Kind("first"))
errors.Is(err, errors.Kind("first")) // output is true

func (Kind) Error

func (k Kind) Error() string

Error returns the error string indicating the kind's error. This is useful for working with std libs errors.Is. It requires an error type.

func (Kind) Is

func (k Kind) Is(target error) bool

Is determines if the error's kind matches. To be used with the std lib errors.Is function.

type StackFrames

type StackFrames []Frame

StackFrames represents a slice of stack Frames in LIFO order (follows path of code to get the original error). TODO:

  1. add String method for this slice of frames so it can be used without fuss in logging
  2. add Formatter to be able to turn off the way it prints

func StackTrace

func StackTrace(err error) StackFrames

StackTrace returns the StackFrames for an error. See StackFrames for more info. TODO:

  1. make this more robust with Is
  2. determine if its even worth exposing an accessor for this private method
  3. allow for StackTraces() to accommodate joined errors, perhaps returning a map[string]StackFrames or some graph representation would be awesome.

func (StackFrames) Format

func (f StackFrames) Format(s fmt.State, verb rune)

Format formats the frame according to the fmt.Formatter interface. See Frame.Format for the formatting rules.

func (StackFrames) String

func (f StackFrames) String() string

String formats Frame to string.

Jump to

Keyboard shortcuts

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