Lightweight nested error management.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Benjamin Shelton 6013d9db96 Added Clone() and SetCode(). 1 month ago
.gitignore Created repository destrealm/go/errors. 11 months ago
LICENSE Re-licensing under the NCSA license for clarity, rather than 3-clause 8 months ago
README.md Added another idea. 8 months ago
danger.go Added dangerous but useful utilities. 5 months ago
errors.go Added Clone() and SetCode(). 1 month ago
errors_test.go Added Clone() and SetCode(). 1 month ago
go.mod Re-homing for consistency with other projects. 1 month ago

README.md

Handle Golang Errors Easier!

This package provides a greatly simplified error management wrapper for Go that behaves somewhat similarly to Dave Cheney's errors package but with far fewer features. If you need stack management or error formatting, consider using his package instead.

destrealm/errors was created somewhat by accident and before I discovered pkg/errors. During the creation of another library, I (re)discovered one of Go's features that is probably its biggest single pain point for large-ish 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 consumer's ability to parse it. For basic errors, this isn't an issue. However, 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. It wraps some of the basic functionality of Go's errors, namely errors.New and fmt.Errorf, both of which are provided as references in this library. However, it also defines an ErrorTracer interface which allows for type switching to be done in order to determine the type of the error.

Usage

General usage of this library starts with errors.NewError with an argument containing a string of the error message, e.g.:

var ErrDecoding = errors.NewError("error decoding JSON input")
var ErrEncoding = errors.NewError("error encoding JSON input")

Then, 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)
}

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.ErrorTracer); 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.
    }
}

To extract and print the original error caught by the code:

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

There are also three separate ways of ensuring returned errors are wrapped by an ErrorTracer:

The first method, .Guarantee(), accepts an error, and “guarantees” that it will be of type ErrorTracer. 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 ErrorTracer such that the original triggering error can be easier to access:

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

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 either the error is of the type specified, or that it is wrapped by the specified error. I haven't found a great deal of use for this specific method, but there are cases where it is warranted, such as ensuring error conditions returned by a function absolutely match a specific type for testing elsewhere via .Is() or .Equal(). For example:

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

func doSomething() error {
    if err := decodeSomething(); err != nil {
        return errors.GuaranteeError(err, ErrDecodeFailed)
    }
}

The third and final installment is relatively new, but there are rare cases where it's also useful, and that is .GuaranteeOrNil(). This function either guarantees that the error is of type ErrorTracer or is nil. There are circumstances where it is useful to either return or embed an ErrorTracer or 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 ErrorTracer
}

c := make(chan ErrorTracer)

// ...

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

errors also provides a mechanism for embedded metadata within an error type, but this is a feature I've found to be largely one I have limited use for. Perhaps one such case might be to store status codes from HTTP clients, e.g.:

var ErrHTTPStatus = errors.NewError("invalid HTTP status")

// ...

response, err := client.Get()
if err != nil {
    return ErrHTTPStatus.Do(err).Add("code", response.StatusCode)
}

// ... elsewhere ...

err := fetch("example.com/some/path")
if e := errors.Guarantee(err); e.Is(ErrHTTPStatus) {
    meta := e.GetMeta()
    if code, ok := meta["code"] {
        fmt.Println("status code returned: %v", code)
    }
}

Useful Patterns

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

In particular, 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, e.g.:

// errors/errors.go
package errors

import "gitlab.com/destrealm/go/errors"

var ErrDecoding = errors.NewError("error decoding values")
var ErrEncoding = errors.NewError("error encoding values")
var ErrReadingResponse = errors.NewError("error reading response")

Then:

// api/client.go
package api

// Notice the "." import.
import (
    . "api" // This would likely point to a FQDN with your project/api package.
)

// ...

if data, err := fetch(); err != nil {
    return ErrReadingResponse.Do(err)
}

It's also helpful to be aware that destrealm/errors exports calls from both the Golang standard library errors and fmt packages. Namely, both erorrs.New and fmt.Errorf are exported as:

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

Limitations

Be aware that unlike pkg/errors, this library does not include any method for examining the error stack trace or even the stack of errors. In theory, you can do this yourself by recursively comparing the contents of .OriginalError(), if it implements the ErrorTracer interface then continue, and print out the tree until you hit the base error type (that is, something that only implements .Error()).

This library is intentionally very spartan and is unlikely to grow in extra functionality. We may eventually implement a method of collecting information from the error stack, in addition to a handful of other useful features. We may also implement a deep-equals to determine if the entire error plus its stack is identical, although this may be unlikely.

Part of the rationale for not including an ability to recursively step through the error stack is that most use cases we've encountered with this library tend toward wrapping Go errors one level deep. Rarely have we required nesting ErrorTracer types, and in most cases, calling .OriginalError() will return the first Go error returned. If you follow this convention, you'll likely find limited need for recursively collating errors wrapped by this library. However, it may be useful to include a .WrapIfNotWrapped() package-level function (or similar) that would only wrap an error within the specified type if a) it isn't already wrapped and b) isn't nil.

Patches

Pull requests are welcome. Please be aware that it is the author's intent that this library remain simple and easy 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

destrealm/errors is offered under an 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 clearly grants you distribution rights for both the software and its associated documentation.

Go here for more information about the NCSA license.