Go 1.13: xerrors

2019-04-28

Part of the Go 2 series of language changes is a new error inspection proposal.

The error inspection proposal adds several features to errors that have been tried elsewhere (in packages such as github.com/pkg/errors), with some new implementation tricks. The proposal has been implemented in tip as preperation for Go 1.13. You can try it out today by working with Go from tip, or by using the package golang.org/x/xerrors with Go 1.12.

The extra features are entirely library-based, no changes to the compiler or runtime are involved. One big new feature is error wrapping.

A worked example: wrapping "key not found"

A product we are building for Tailscale includes a simple key-value-store called taildb. As with many simple KV-stores, you can read key-values. Nothing fancy:

// Get fetches and unmarshals the JSON blob for the key k into v.
// If the key is not found, Get reports a "key not found" error.
func (tx *Tx) Get(k string, v interface{}) (err error)

Let's talk about "key not found."

Version 1

The very first API version defined the "key not found" error as:

var ErrNotFound = errors.New("taildb: key not found")

Code that used taildb could use it easily:

var val Value
if err := tx.Get("my-key", &val); err == taildb.ErrNotFound {
	// no such key
} else if err != nil {
	// something went very wrong
} else {
	// use val
}

This was fine until I was doing some debugging and ran across a log entry that boiled down to:

my_http_handler: taildb: key not found

…which is not a very informative error message.

Version 2

Given that the Get method has the key name, it would be nice to include it in the error message.

So I followed a common strategy in Go of introducing an error type into the taildb package:

type KeyNotFoundError struct {
	Name string
}

func (e KeyNotFoundError) Error() string {
	return fmt.Errorf("taildb: key %q not found")
}

This works well! The code that checks for this specific error is a tiny bit messier, but it works:

var val Value
err := tx.Get("my-key", &val)
if err != nil {
	if _, isNotFound := err.(taildb.KeyNotFoundError); isNotFound {
		// no such key
	} else {
		// something went very wrong
	}
} else {
	// use val
}

But this style of direct matching has a flaw. If any intermediate code adds information to the error we can no longer check the type of the error. Consider a function like:

func accessCheck(tx *taildb.Tx, key string) error {
	var val Value
	if err := tx.Get(key, &val); err != nil {
		return fmt.Errorf("access check: %v", err)
	}
	if !val.AccessGranted {
		return errAccessDenied
	}
	return nil
}

Here we are implementing logic on top of the database, checking if the user has some sort of access. Reporting a nil error grants access, otherwise access is denied. The reason for denying access might be !AccessGranted or some underlying database error. All the textual information about the error is preserved, but the use of fmt.Errorf means that we can no longer type-check to see if the access error was a KeyNotFoundError.

Version 3

The new xerrors library fixes this by providing a version of Errorf that preserves the underlying error object inside the new error:

	if err := tx.Get(key, &val); err != nil {
		return xerrors.Errorf("access check: %w", err)
	}

%w for wrap.

On the surface this implementation of Errorf works exactly as the one in fmt does. Under the hood, the preserved type means we can now check the cause chain for our KeyNotFoundError:

var val Value
if err := accessCheck(tx, "my-key"); err != nil {
	var notFoundErr taildb.KeyNotFoundError
	if xerrors.As(err, &notFoundErr) {
		// no such key
	} else {
		// something went very wrong
	}
} else {
	// use val
}

Great!

Version 4

We can do even better. The only reason we replaced the exported KeyNotFoundError was so we could put a little extra text in the error message while making the type testable. The new xerrors gives us an easier way to do that.

So let's return to the very first definition:

var ErrNotFound = errors.New("key not found")

Inside taildb we can write:

func (tx *Tx) Get(k string, v interface{}) (err error) {
	// ...
	if noSuchKey {
		return xerrors.Errorf("taildb: %q: %w", k, ErrNotFound)
	}
}

All the information we want is here. When we print the error to a log we see taildb: "my-key": key not found. To check the returned error from accessCheck we can write:

var val Value
if err := accessCheck(tx, "my-key"); xerrors.Is(err, taildb.ErrNotFound) {
	// no such key
} else if err != nil {
	// something went very wrong
} else {
	// use val
}

Easy!

Go 1.13

The new xerrors is due to be promoted into the standard library's errors package in Go 1.13.

Instead of xerrors.Errorf, the chaining is being built directly into the fmt.Errorf function we use today:

If the last argument is an error and the format string ends with ": %w",
the returned error implements errors.Wrapper with an Unwrap method returning it.

Certainly this looks nice. However, Go 1.13 is only three months away! After that, all of these new changes (and this post only covers one) will be frozen forever in the standard library under the Go 1 compatibility promise. For such a high standard, this package is woefully under-tested.

I would encourage you to start using golang.org/x/xerrors today, or even better, start developing directly against Go tip by installing from source. More people need to give this a go.


Index
github.com/crawshaw
twitter.com/davidcrawshaw
david@zentus.com