errors

package module
v2.0.0-...-c19df89 Latest Latest
Warning

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

Go to latest
Published: Aug 24, 2024 License: NCSA Imports: 4 Imported by: 0

README

Handle Golang Errors Easier!

This package provides a powerful error management library and wrapper for Go without some of the complexity found in other similar libraries.

Why go/errors?

go/errors explores some of the most common use cases shared among Go error handling libraries, such as top-level error types, per-error metadata, and error chaining; however, it avoids some of the more complex interfaces related to stack management or peeling back the veneer beneath the Golang runtime. If you need something with greater complexity with a robust featureset that doesn't simply end with error management, go/errors is not for you. If you want an error management interface that lets you define specific error types, wrap errors from the standard library or the runtime, and inject metadata (key/value- or code-based) without too much additional fuss then this library is for you.

A Brief History

go/errors was created somewhat by accident and before I discovered the existence of other error management libraries. During the creation of go/errors, I (re)discovered one of Go's "features" (anti-features?) that is its biggest single pain point for large applications and one for which there's been much discussion. That is, of course, error management and the ability--or lack thereof--to include extra data within an error message without ruining the error consumer's ability to parse it. For basic errors, this isn't an issue. For more complex conditions where upstream consumers may need to know or report additional information to the user or developer, this quickly causes a descent into chaos.

The semantics of this library are somewhat similar to others with a slow convergence toward a feature set that mimics part of the standard library. It wraps the majority of Go's error functionality, namely errors.New and fmt.Errorf (with basic Unwrap support), both of which are provided as references in this library. It also defines an Error interface that builds upon features missing from the standard library. Included are a number of convenience functions to ensure that the returned error type can be handled identically to anything relying on this library, and it presents the same Error interface through, for example, functions conveniently named Guarantee that guarantee wrapped errors behave like the rest of the library you know and love.

Usage

To install the latest version of the library:

$ go get git.destrealm.org/go/errors/v2

(Other mirror sites may be available.)

General use of this library centers on creating top-level error types with errors.NewError by passing in an argument containing a string with the error message:

var ErrDecoding = errors.NewError("error decoding JSON input")
var ErrEncoding = errors.NewError("error encoding JSON input")
var ErrReading = errors.NewError("unable to read stream")

When you encounter a condition where this error is necessary to return, you can include the upstream error as part of your defined error via .Do():

if err := json.Unmarshal(data, map); err != nil {
    return ErrDecoding.Do(err)
}

if b, err := json.Marshal(data); err != nil {
    return ErrEncoding.Do(err)
}

or return the error itself:

func DoSomething() error {
    if /* something fails */ {
        return ErrReading
    }
}
Comparison and Equality

For comparing errors, two methods are provided to determine whether an error is of a particular type (.Is()) or whether it's equal to another error (.Equal()):


var err = errors.New("this is an error")
var ErrExample = errors.NewError("this is an example error")

// ...

if e, ok := someError.(errors.Error); ok {
    if e.Is(ErrExample) {
        // True if someError is of the type ErrExample.
    }

    if e.Equal(ErrExample) {
        // True if e is the same as ErrExample. If .Do is called in this
        // example, this will not be true. This compares both ErrExample and
        // the contents of the original error message that it wraps.
    }

    if e.DeepEqual(ErrExample) {
        // True if e and its descendants are the same as ErrExample. For long
        // error chains, this may be useful.
    }
}

Top-level functions are provided to similar means:

if errors.Is(err, ErrExample) { // False as per the prior example.
    // [ ... ]
}

if errors.Equal(err, ErrExample) { // Ditto.
    // [ ... ]
}

if errors.DeepEqual(err, ErrExample) { // Dito.
    // [ ... ]
}
Error Chaining, Output, and Chain Traversal

To extract and print the initiating error caught by the code and wrapped by go/errors:

if e, ok := err.(errors.Error); ok {
    fmt.Println(e.Initiator())
}

To pretty-print the entire error chain leading up to the current error, Unfurl may be used:

err := DoSomething()
fmt.Println(errors.Unfurl(err))

If err is of the standard library error type, this will simply return the error as a string. If err implements the errors.Error interface, this will unfurl the error, unwinding lengthy error chains with output similar to:

error opening file:
 -> file was not readable
 -> file did not exist

If you need to handle the error chain yourself, there is a function analogous to Unfurl that accepts a callback function with the signature func(error, error) error. This callback will receive the parent error that triggered the UnfurlOn as its first argument, and the second argument will receive the next error in the error chain. This function will be called until the chain is exhausted.

If the callback function itself returns an error, UnfurlOn will abort and return the error returned by the callback. This allows the callback to abort the error chain traversal at any point, or it may potentially return a customized error message depending on the circumstance. As an example of how to use UnfurlOn from go/errors' own library code:

// Contains returns true if the parent error contains err.
func Contains(parent, err error) bool {
	return UnfurlOn(err, func(p, child error) error {
		if Is(err, child) {
			return child
		}
		return nil
	}) != nil
}

In this case, the parent error is ignored as we're only interested in the error inheritance tree.

Automatic Conversion to Error

There are three separate ways to ensure returned errors are wrapped by an Error type:

The first method, Guarantee(), accepts an error, and "guarantees" that it will be of type Error. If it's not, it will be wrapped by the errors.Error type. This reduces some boilerplate that would otherwise be required to perform a cast to Error such that the original triggering error can be easier to access:

if err != nil {
    fmt.Println("initiating error:", errors.Guarantee(err).Initiator())
}

The second method, GuaranteeError(), accepts two arguments: An error, raised elsewhere, and an error type returned by errors.NewError. This attempts to guarantee one of two things: That the error is of the type specified, or that it is wrapped by the specified error. This method is mostly helpful in a narrow subset of use cases, such as cases where a sepcific error type is absolutely required; but, where it is warranted, it can be incredibly useful. For example:

var ErrDecodeFailed = errors.NewError("decode failed")

func doSomething() error {
    if err := decodeSomething(); err != nil {
        // If err is ErrDecodeFailed, return it. Otherwise, wrap the error.
        return errors.GuaranteeError(err, ErrDecodeFailed)
    }
}

This would reduce the likelihood of creating a wrapped error chain if err is already ErrDecodeFailed, but if it is not, then it will generate the error chain with the err wrapped as its initiator.

The third and final installment is GuaranteeOrNil(). This function either guarantees that the error is of type Error or it is nil. There are circumstances where it is useful to either return or embed an Error or return nil nil if there is no error, such as when wrapping a response in an outer type plus its error condition for dispatch over a channel:

type Result struct {
    Data map[string]string
    Error Error
}

c := make(chan Error)

// ...

data, err := doSomething()
result := &Result{
    Data: data,
    Error: errors.GuaranteeOrNil(err),
}
c <- result

The author has personally used this method in precisely this construct, using channels, to determine on the receiver whether the error type was, in fact, nil or otherwise.

Embedding Metadata

errors also provides a mechanism for embedded metadata within an error type. This can be used for storing information related to a failed process, such as a path that couldn't be read, or a failed HTTP endpoint:

var ErrHTTPConnect = errors.NewError("HTTP(S) connection failed")

// ...

response, err := client.Get(url)
if err != nil {
    return ErrHTTPConnect.Do(err).Add("url", url)
}

// ... elsewhere ...

err := fetch("example.com/some/path")
// First, validate the type.
if e := errors.Guarantee(err); e.Is(ErrHTTPConnect) {
    meta := e.GetMeta()
    // Then extract the metadata.
    if endpoint, ok := meta["url"] {
        fmt.Println("endpoint failed: %s", endpoint)
    }

    // Optionally writing the above instead as:
    if endpoint, ok := e.GetString("url"); ok {
        fmt.Println("endpoint failed: %s", endpoint)
    }
}

Note that the order of calls, above, is important: If you call .Add() before .Do(), a copy of the error will be generated with the attached metadata but the error returned from .Do() will contain a pristine copy of the source error (minus the metadata!). To rectify this, you can guarantee that the wrapped error will always contain the expected metadata by calling .Wrap():

// ...
return ErrHTTPConnect.Add("url", url).Wrap(err)

as .Wrap() retains the original copy of the source metadata.

.Wrap() versus .Do()

As mentioned in the previous section, .Wrap() retains metadata from the source error whereas .Do() returns a pristine copy of the error with its argument configured as the originating source. For this reason .Wrap() may be preferrable to use over .Do() as the order of calls becomes unimportant.

However, .Wrap() should never be called on package level error. For instance, the following will result in modifications to the package level metadata which is probably not what you want:

var ErrHTTPConnect = errors.NewError("HTTP(S) connection failed")

// do something
return ErrHTTPConnect.Wrap(err).Add("url", url)

This will modify the package level error, ErrHTTPConnect, adding the url value to its metadata.

In this case, you should either specify .Add() first or create a .Copy() of the error.

Generally speaking .Wrap() should be used in favor of do precisely because the only way to guarantee that package level errors are not modified is to either:

err := ErrHTTPConnect.Add("url", url)

or:

err := ErrHTTPConnect.Copy()

(.Add() calls .Copy() internally.)

Printing Embedded Metadata

errors can print formatted output for embedded metadata dependent upon user-generated code. When paired with its metadata storage support, this can be useful for dynamically printing detailed information about an error.

A contrived example of this is:

var ErrWritingFile = errors.NewError("error writing file").
    SetFormatter(func(e errors.Error) string {
        written, _ := e.GetInt("written")
        expected, _ := e.GetInt("expected")
        return fmt.Sprintf("%s: wrote %d byte(s); expected %d", e.Error(),
            written, expected)
    })

func Write(fp *os.File, data []byte) error {
    n, err := fp.Write(data)
    if n != len(data) || err != nil {
        return ErrWritingFile.Do(err).
            Add("written", n).
            Add("expected", len(data))
    }
}

func DoWrite() {
    // Assume fp and data are defined elsewhere.
    if err := Write(fp, data); err != nil {
        fmt.Println(errors.Guarantee(err).String())
    }
}

where we configure a formatter function (implementing the signature errors.ErrorFormatterFunc) and pass it in via the error type's SetFormatter. We then extract this formatted error by calling Error.String().

Useful Patterns

Although this section needs to be expanded upon, there are some useful patterns that this library lends itself to.

Separate errors.go file or errors package

Placing common errors into their own errors.go file can be helpful for clients of your code to locate common error types that may be returned in case of a failure. Likewise, creating your own errors package separate from the rest of your project and inserting all errors.NewError statements in one or more files allows for an easy import of all possible error types, especially for big projects.

This technique helps create self-documenting code and encourages developers to provide explanatory comments for each error.

For example:

// errors/errors.go
package errors

import "git.destrealm.org/go/errors/v2"

// ErrDecoding is returned when our parser is unable to decode a value.
var ErrDecoding = errors.NewError("error decoding values")

// ErrDecoding is returned when our parser is unable to encode a value.
var ErrEncoding = errors.NewError("error encoding values")

// ErrReadingResponse is returned when our consumer is unable to read the
// response returned from the remote host but is never raised when a timeout
// condition is reached.
var ErrReadingResponse = errors.NewError("error reading response")

Then:

// api/client.go
package api

// Notice the "." import.
import (
    . "example.com/mypackage/errors" // Top level import. No name collision.
    "git.destrealm.org/go/errors/v2" // Imported for utility functions.
)

// ...

if data, err := fetch(); err != nil {
    return ErrReadingResponse.Do(err)
}
go/errors imports common types

It's also helpful to be aware that errors exports argument-compatible calls with the Golang standard library errors and fmt packages. Namely, both errors.New and fmt.Errorf are exported as and can be used as drop-in replacements:

e1 := errors.New("...")
e2 := errors.Errorf("...")

This means it is possible to create an ad hoc error type to wrap with .Do():

return ErrReadingResponse.Do(errors.Errorf("received status code %d",
    response.StatusCode))

Further, being as these are simply Error types that implement the error interface, no additional objects need to be created when setting other attributes (such as error codes); only an interface comparison will be performed:

return ErrReadingResponse.Do(errors.Guarantee(errors.New("received error code")).SetCode(response.StatusCode))

However, it's generally inadvisable to create ad hoc errors as this circumvents the purpose of having typed errors, but it may be useful for one-off conditions or wherever there is no other potential initiator type that makes sense. It's also useful if the error string itself needs to be customized to include more specific data as per the failure.

We typically recommend using the .Add() or .Attach() functions to instead associate metadata with the error (using .Add() when dealing with package-level errors as it works on a copy!).

Use .Copy() or .Add() to replicate top-level errors to store metadata.

...rather than .Attach().

Errors created with errors.NewError() are typically considered to be the "top-level" error type declaration and shouldn't be modified directly. As such, both .Add() and .SetCode() will return copies of the error before modifying their internal state (be it metadata or error code). However, there may be circumstances where you'll want to deliberately create a copy of an error to work on it. Each of the following do roughly the same thing:

var ErrReadingFile = errors.NewError("could not open file for reading")

// Add metadata to our error, returning a copy.
if fp, err := mypackage.Open("file.db"); err != nil {
    return ErrReadingFile.Add("path", "file.db")
}

// Add metadata to our error, copying it first.
if fp, err := mypackage.Open("file.db"); err != nil {
    return ErrReadingFile.Copy().Add("path", "file.db")
}

// Set an error code, returning a copy.
if fp, err := mypackage.Open("file.db"); err != nil {
    return ErrReadingFile.SetCode(100)
}

// Set an error code, copying it first.
if fp, err := mypackage.Open("file.db"); err != nil {
    return ErrReadingFile.Copy().SetCode(100)
}

Note: .Do() and .Wrap() perform double-duty here. They replicate the top-level error and attach its error argument as part of the error chain.

Use .Attach() to associate metadata with a top-level error

.Add(), as a rule, will only ever interact with copies of top-level error types. In fact, internally, .Add() will create a copy of top-level errors if they haven't already been copied. Sometimes this isn't ideal. As such, .Attach() has been introduced to address this.

// ErrInitialization should be returned during our initialization phase,
// whenever any start-up error is encountered. This will wrap the originating
// error.
var ErrInitialization = errors.NewError("error initializing product")

// Elsewhere.

const Version = "1.0.1-release"

func init() {
    ErrInitialization.Attach("version", Version)

    if err := initializeProduct(); err != nil {
        if errors.Is(ErrInitialization, err) {
            fmt.Printf("failed to initialize v%s\n\n",
                errors.Guarantee(err).GetString("version"))
        }
    }
}

Limitations

This library may not include all of the features of other error handling libraries while introducing concepts that some of them might lack. Features are largely added as the library author(s) encounter use cases where they would simplify development or reduce the amount of work required to handle particular conditions instigated by common circumstance. One-off or otherwise rare applications of varyingly complex solutions using go/errors are likely to be ignored except in cases where they might eliminate some boilerplate.

Previous versions of this README mused about the possibility of adding a function analogous to WrapIfNotWrapped() that would only wrap an error within the specified type if a) it isn't already wrapped and b) isn't nil. GuaranteeError() currently fills this void. Likewise, other common features (error chain traversal, equality testing, etc) have been resolved.

Patches

Pull requests and patches are welcome. Please be aware that it is the author's intent that this library remain comparatively simple to use. As this documentation is also rather bare, if you would like to add anything to make it clearer without sacrificing its conciseness, such requests are also welcome.

License

go/errors is offered under the NCSA license, which is essentially a 3-clause BSD license combined with the MIT license for clarity. This is the same license used by the LLVM and Clang projects and arguably resolves issues that affect both the BSD and MIT licenses individually. In particular, this license grants clear distribution rights for both the software and its associated documentation (such as this README).

go/errors is copyright (c) 2019-2021 Benjamin A. Shelton. Attribution is required.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var As = errors.As

As wrapps the standard library errors.As function. Future versions may expand this capability.

v1.3.1: Added to accomodate certain use cases exposed by the stdlib errors package.

View Source
var ErrNotImplemented = NewError("not implemented")

ErrNotImplemented should be returned by methods or functions that are stubs not presently implemented but are planned to be implemented at a future date.

View Source
var Errorf = func(s string, a ...interface{}) error {
	return NewError(fmt.Errorf(s, a...).Error())
}

Errorf is a convenience reference to fmt.Errorf.

v1.3.0: This now wrapps fmt.Errorf returning an Error type instead.

View Source
var New = func(s string) error {
	return NewError(s)
}

New is a convenience reference to errors.New. This avoids having to import this package with an alias.

v1.3.0: This now wraps erorrs.New returning an Error type instead.

Functions

func Consume

func Consume(v interface{}, err error) interface{}

Consume the error returning the value without consideration for whatever might be happening upstream. Useful if you're uninterested in the error itself and already perform nil checks on the returned value.

func Contains

func Contains(parent, err error) bool

Contains returns true if the parent error contains err.

func DeepEqual

func DeepEqual(err1, err2 Error) bool

DeepEqual uses Hash() to validate the "deep equality" of the errors. The errors are themselves traversed, hashed, and the hashes compared. Errors with identical error trees will be equal; others will not.

func Equal

func Equal(err1, err2 Error) bool

Equal checks whether the two errors are equal to each other.

As with Is() this function shares the same limitations and is a simplistic check that only validates the contents of both errors and their strings. However, this does go somewhat further by also checking the value of Initiator().

Note that this may fail somewhat spectacularly if it is used on errors that are wrapped multiple levels deep or if one of the arguments is nil. At present, there are no attempts to guard against either scenario.

TODO: Fix this. Also migrate Error.Equal to use this instead.

func Hash

func Hash(err error) uint64

Hash returns the CRC64 hash of the provided error tree.

Presently, this only works with error trees using Error. Eventually, support will be provided for stock Golang error trees.

func IfNotError

func IfNotError(err error, e Error) error

IfNotError wraps `err` with `e` if `err` is not of type Error. Otherwise returns `err` as `Error`.

This function is mostly useful in cases where you wish to wrap `err` if and only if it's a stdlib error and not a go/errors type.

func Is

func Is(err1, err2 error) bool

Is compares whether err1 is of the same inherited base error as err2.

There are some limitations with this sort of basic check. There's no reflection done to guarantee they're the same type, and only the error strings themselves are actually compared. This means that the comparison is fast but not necessarily accurate. For most projects, this should be sufficient.

TODO: Migrate Error.Is to use this instead.

func IsNil

func IsNil(err Error) bool

IsNil indicates whether the error `err` is a nil error type or not.

This is only useful in cases where Guarantee() was called on a nil error value but isn't generally useful otherwise.

func Must

func Must(v interface{}, err error) interface{}

Must ensures that the error is nil; if it's not, it panics. This is likely the most useful of the functions defined here but beware that it disrupts program flow and should ONLY be used in circumstances where a panic makes sense (e.g. program flow cannot continue due to disrupted internal state).

func Unfurl

func Unfurl(err error) string

Unfurl returns a string containing the chain of events leading up to the final error.

func UnfurlOn

func UnfurlOn(err error, fn func(error, error) error) error

UnfurlOn traverse the err's error tree and calls fn on each component.

fn's first argument is the parent error; the second argument is the current error in the traversed tree.

If this is called on a nil error type, fn will receive fn(nil, nil). If fn returns a non-nil error type, tree traversal will be interrupted and the error returned.

If the error cannot be traversed at any point, the last error in the chain will be returned. If the parent error is non-traversable, both of fn's arguments will be set to the same error.

func Unwrap

func Unwrap(err error) error

Unwraps the specified error. If the error wraps another error type that implements Error, this will call its Unwrap method. Otherwise, if the type does not implement Error, this will return nil.

This is provided for compatibility with the stdlib errors package.

Types

type Error

type Error interface {
	Add(string, interface{}) Error
	Attach(string, interface{}) Error
	Clone() Error
	Code() int
	Copy() Error
	Do(error) Error
	DoIfNot(error) Error
	Wrap(error) Error
	Equal(Error) bool
	Error() string
	Get(string) (interface{}, bool)
	GetError(string) (error, bool)
	GetInt(string) (int, bool)
	GetFloat(string) (float64, bool)
	GetString(string) (string, bool)
	GetMeta() map[string]interface{}
	ReplaceMeta(map[string]interface{}) Error
	SetCode(int) Error
	SetFormatter(ErrorFormatterFunc) Error
	String() string
	Is(error) bool
	DeepEqual(Error) bool
	Initiator() error
	OriginalError() error
	Unwrap() error
}

Error is a container interface for several utility and convenience methods for error management, encapsulation, tracing, and more. It has evolved to combine methods analogous to the stdlib "errors" package with a number of additional functions for common tasks (setting error codes, replicating errors, etc).

func Clone

func Clone(err Error) Error

Clone the error creating a new instance.

Be aware that this only creates a "shallow" clone. All internal pointers are retained in their original state. This is probably not what you want.

See Copy().

func Copy

func Copy(err Error) Error

Copy returns a replica of the specified error instance with most of its internal points configured identically to the parent with the exception of its metadata, which is reinitialized for each Copy().

func Guarantee

func Guarantee(err error) Error

Guarantee returns an Error regardless of the error's underlying type.

If Guarantee is called on a nil type, this will return a nilError that behaves identically to Error with the exception that it usually returns nil or false, depending on the nature of the method called.

Other error types will be wrapped accordingly.

Future versions of this *may* unwrap the specified error if it implements the stdlib Unwrap() API. This addition will be entirely transparent and should not affect future code.

func GuaranteeError

func GuaranteeError(err error, e Error) Error

GuaranteeError ensures that the error `err` is of type `e`. If not, it will be wrapped and returned.

This is equivalent to calling e.Wrap(err) if e != err.

func GuaranteeOrNil

func GuaranteeOrNil(err error) Error

GuaranteeOrNil guarantees the return of an Error or nil, depending on the original type of err.

Code that absolutely requires a nil type in place of an error should use this function as Guarantee will *always* return a compatible Error type, even if it is a wrapped nilError.

func NewError

func NewError(err string) Error

NewError creates a new Error instance with the specified error string. Start here when defining new traceable errors.

func NewErrorWithCode

func NewErrorWithCode(err string, code int) Error

NewErrorWithCode creates a new Error instance with the specified error message and code values pre-set.

type ErrorFormatterFunc

type ErrorFormatterFunc func(Error) string

ErrorFormatterFunc defines a function type that accepts an error and returns a formatted string.

Examples of what this function might do include consuming an error and returning a formatted string containing appropriate metadata entries, such as error codes, URLs, or strings.

The current implementation of Error will only call functions implementing this type if String() is called. Error() will always return the error string.

type ErrorTracer

type ErrorTracer = Error

ErrorTracer exports a type alias to ensure compatibility with v1.0.0 code.

Jump to

Keyboard shortcuts

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