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.
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.)
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!).
...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.
.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.