Error wrapping in Go

Error wrapping in Go

For the robust, an error is information; for the fragile, an error is an error.

—Nassim Nicholas Taleb, “The Bed of Procrustes: Philosophical and Practical Aphorisms”

This is Part 3 of a series about errors and error testing in Go:

  1. Testing errors in Go
  2. Comparing Go error values
  3. Error wrapping in Go

In Part 1 we talked about how to test simple errors, and in Part 2 we learned how to compare the values of those errors with what we expect.

In this final part, we’ll see how to overcome the limitations of sentinel errors in Go by using error wrapping, and we’ll also look at custom error types and how to distinguish different kinds of errors using the errors.Is and errors.As functions.

Detecting sentinel errors with errors.Is

In the previous part of this series, we saw that for very simple cases, a fixed error value (known as a sentinel error) is fine. And since all that matters about an error like this is that it’s not nil, that’s pretty easy to test, or check for at runtime.

We don’t need to worry about what the specific error value is, because we don’t usually need to take different actions for different errors. Instead, we just report the error to the user and leave it up to them what to do.

What about when different errors should result in the code taking different actions, though? Even if most of the time this isn’t necessary, it’s worth asking how we should go about constructing distinct errors when that’s appropriate.

For example, suppose we’re writing a function that makes HTTP requests to some API; let’s call it Request. One of the possible responses we can get is HTTP status 429 (Too Many Requests), indicating that we are being ratelimited.

How could we indicate this in an error returned from our function? A sentinel value would be fine here:

var ErrRateLimit = errors.New("rate limit")

Then we can use it like this:

if resp.StatusCode == http.StatusTooManyRequests {
    return ErrRateLimit
}

(Listing req/1)

So, how can we test that the function indeed returns this error value when it gets a ratelimit response? First, we’ll need to create some local HTTP server that just responds to any request with the ratelimit status code:

func newRateLimitingServer() *httptest.Server {
    return httptest.NewServer(http.HandlerFunc(
        func(w http.ResponseWriter, r *http.Request) {
            w.WriteHeader(http.StatusTooManyRequests)
        }))
}

(Listing req/1)

Great. Now we have a test server whose URL we can pass to Request, and the result should always be ErrRateLimit. But how can we check that result in a test? The answer is to use errors.Is:

if !errors.Is(err, req.ErrRateLimit) {
    t.Errorf("wrong error: %v", err)
}

(Listing req/1)

Wrapping sentinel errors with dynamic information

A sentinel error is pretty limited in what it can convey. It can’t contain any dynamic information that we’ve learned at run-time: it’s just a fixed string, and sometimes that’s fine, as in the ratelimit example.

But often we’d like to include some more dynamic information in the error. In our store.Open example from Part 1, that might be the full err value returned from os.Open. How can we incorporate that information into our sentinel error?

One way to do this is to take the sentinel value and wrap it in a new error value that contains the dynamic information. For this, we can use the fmt.Errorf function, and the special format verb %w (for “wrap”):

var ErrUserNotFound = errors.New("user not found")

func FindUser(name string) (*User, error) {
    user, ok := userDB[name]
    if !ok {
        return nil, fmt.Errorf("%q: %w", name, ErrUserNotFound)
    }
    return user, nil
}

(Listing user/2)

The calling code, if it cares, can then use errors.Is to ask if the error result was originally ErrUserNotFound:

func TestFindUser_GivesErrUserNotFoundForBogusUser(t *testing.T) {
    t.Parallel()
    _, err := user.FindUser("bogus user")
    if !errors.Is(err, user.ErrUserNotFound) {
        t.Errorf("wrong error: %v", err)
    }
}

(Listing user/2)

Now we can see why using errors.Is beats comparing errors directly with the == operator. A direct comparison wouldn’t succeed with a wrapped error. But errors.Is can inspect the error on a deeper level, to see what sentinel value is hidden inside the wrapping.

Custom error types and errors.As

In some older Go programs, written before error wrapping was introduced, you’ll see a custom error type used to achieve roughly the same goal. This is usually some struct type, for example, where the type name conveys what kind of error it is, and the struct fields convey the extra information:

type ErrUserNotFound struct {
    User string
}

func (e ErrUserNotFound) Error() string {
    return fmt.Sprintf("user %q not found", e.User)
}

(Listing user/3)

And there’s a special function to check whether an error is one of these named types. Instead of errors.Is, we use errors.As:

if errors.As(err, &ErrUserNotFound{}) {
    ... // this is a 'UserNotFound' error
}

There’s no need to use custom error types anymore. In most situations the best thing to do is to wrap a sentinel error using fmt.Errorf and %w.

Go’s best-kept secret: executable examples

Go’s best-kept secret: executable examples

Standalone test scripts

Standalone test scripts

0