More than you ever wanted to know about errors

Jason Mavandi

@mvndaai

My Background

The reason I know so much about errors

I have worked in Go for around five years. 

 

Almost two years ago, my job asked me to design a way to have all APIs return a common JSON errors response.

 

I iterated over time on a helper package to make that common response easier. I have spent at least 100 hours on go errors and thought it would be nice to share.

 

Here is a public version of my current error implementation for creating a common response:

https://github.com/mvndaai/ctxerr

Overview

The Plan

We are going to learn about errors by creating a package called caterr that will help compare errors to match categories. This will use the new Is, As, and Unwrap functions from Go1.13.

 

Here is my repo as a reference:

https://github.com/mvndaai/caterr

panic, defer, recover

The try/catch of go

func main() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered:", r)
        }
    }()
    panic("panicking")
}

// Recovered: panicking

Example

Don't Panic

When is it OK to panic?

Panicking breaks the flow of an application. Go has a better way of handling errors that does not need to break the flow.

 

I agree with Mat Ryer who says the only time you should panic is to make another panic more readable.

When should I recover?

Every application should have a "Pokémon" recover function to "catch 'em all". This prevents imported packages and unexpected panics in your code from completely crashing your application.

error

The power of interfaces

type error interface {
    Error() string
}

What is an error?

An error in go is just an interface

Any struct that includes a function Error() string fulfills the interface. Go allows multi-value returns. Although it is not required, the standard is if an error is returned, it is the last return named err when defined.

func foo() (int, error) {
bar, err := foo()
if err != nil {
    // handle the error
}
func isZero(i int) error {
    if i != 0 {
        return errors.New("was not zero")
    }
    return nil
}

func isZero(i int) error {
    if i != 0 {
        return fmt.Errorf("it was '%s' not zero", i)
    }
    return nil
}

Creating an error

The normal way of creating an error is by using the errors or fmt packages.

package caterr

type Interface interface{
    error
    Category() interface{}
}

type impl struct {
    message string
    category interface{} //this will make sense later
}

func New(category interface{}, message string) error {
    return &impl{ //This needs to be a pointer to allow nil
        message: message,
        category: category,
    }
}

func (e *impl) Error() string {
    return e.message
}

func (e *impl) Category() interface{} { return e.category }

Follow Along

Create a file called caterr.go and copy this:

What do you do with errors?

Return, decorate, and handle

func foo() (int, error) {
    a, err := bar()
    if err != nil {
        return 0, err
    }
    return a, nil
}

Normal Flow - Pass it up

A zero value of an interface in go is nil. If a function returns an error, check if it exists by if err != nil. If it does exist, pass it up.

Exiting the function at the first sight of an error makes go code safer than most languages.

func foo() error {
    if err != nil {
        return fmt.Errorf("in foo(): %s", err.Error())
    }

Decoration

The package "github.com/pkg/errors" popularized the use of a Wrap function.

2
 Advanced issues found
2
func foo() error {
    if err != nil {
        return errors.Wrap(err, "in foo()")
    }

Decorate an error to add more context. Skip capitalization and punctuation since you do not know how many times it will be decorated.

func foo() error {
    ...
    if err != nil {
        return fmt.Errorf("decoration: %w", err)
    }
    ...
}

%w

Go 1.13 allows "%w" in fmt.Errorf to wrap an error.

 

Caution: This is a non-backward compatible change. If you use this Go 1.13 or higher is required.

func main(){
    if err := foo(); err != nil{
        log.Println("Caught an error:", err)
    }
}

Handling

When you make it to the top of a tree and cannot pass it up, handle the error. Only handle an error once. Common places for handling are main, http handlers, defer, and go functions.

func background() {
    go func() {
        if err := foo(); err != nil {
            fmt.Println("Error from go func:", err)
        }
    }()
}
// Update the struct
type impl struct {
    message string
    category interface{}
    wrapped error // this is new
}

Follow Along

Allows our error to wrap other errors.

// Add this function
func Wrap(err error, category interface{}, message string) error {
    return &impl{
        message: message,
        category: category,
        wrapped: err,
    }
}

Why change errors?

Type checking?

if err != nil {
    switch v := err.(type) {
    case NotFound:
        // ...
    }
    return err
}

Type Handling

People want the ability to find out what kind of error is being handled.

This has become more complicated with the popularity of decorating errors. Using fmt.Error without "%w" removes the type, and Wrapping covers other types in a chain.
 

error chain: error -> NotFound -> DBNotFound -> error

Proposal

A proposal was made to standardize on the ideas from the "github/pkg/errors" package to allow all errors to be decorated by wrapping.

 

An implementation that works on versions previous to Go 1.13 is xerrors.

 

https://godoc.org/golang.org/x/xerrors

 

Additions to errors

Unwrap, As, Is

// interface { Unwrap() error }

func Unwrap(err error) error {
	u, ok := err.(interface {
		Unwrap() error
	})
	if !ok {
		return nil
	}
	return u.Unwrap()
}

Unwrap

There is a new function errors.Unwrap(). This returns an error that has been wrapped.

 

Unwrap expects errors to fulfill an interface of Unwrap() error.

// Update the interface
type Interface interface {
    error
    Category() interface{}
    Unwrap() error // add this
}

Follow Along

Allows our error to wrap other errors.

// Add this function
func (e *impl) Unwrap() error { 
    return e.wrapped
}
// interface{ As(interface{}) bool }

func As(err error, target interface{}) bool {
    ...
    if x, ok := err.(interface{ As(interface{}) bool }); ok && x.As(target) {
        return true
    }
    ...
}

As

errors.As also uses an interface. This takes an error and a pointer to a target type. It will unwrap the error until the error's As function returns true. If it does the pointer will point to that error in the chain.

You get to decide what a match means.

1
// Update the interface
type Interface interface {
    error
    Category() interface{}
    Unwrap() error
    As(interface{}) bool // add this
}

Follow Along

Allows our error to use As.

 
// Add this function
func (e *impl) As(err interface{}) bool {
    if c, ok := err.(Interface); ok { //note the uses of our interface
        return c.Category() == e.Category()
    }
    return false
}
// interface{ Is(error) bool 

func Is(err, target error) bool {
    ...
        if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
            return true
        }
    ...
}

Is

 

errors.Is compares two errors and sees if they match.

The match is determined by the error fulfillment of an interface.

Caution this could be misused.

1
func (e *impl) Is(err error) bool {
    return true // this will always match
}
if err != nil {
    if errors.Is(err, NotFound) {
        //...
    }
    return err
}

Solving the type issue

Is finally solves the issue with determining if errors are of a type.

Note: A switch cannot be used because the function call is necessary.

// Update the interface
type Interface interface {
    error
    Category() interface{}
    Unwrap() error
    As(interface{}) bool 
    Is(error) bool // add this
}

Follow Along

Allows our error to use Is.

// Add this function
func (e *impl) Is(err error) bool {
    return e.As(err)
}

Testing our package

Did it work?

import (
    "errors"
//    errors "golang.org/x/xerrors" // if your go version < 1.13
)


func HasCategory(err error, category interface{}) bool {
    return errors.Is(err, &impl{category: category})
}

Follow Along

Helper function wrapping errors.Is

 
package caterr

import (
    "errors"
    "fmt"
    "testing"
)


func TestWrap(t *testing.T) {
    type s string
    categories := []interface{}{1, "category", s("category")}
    for _, category := range categories {
        err := errors.New("errors")
        if HasCategory(err, category) {
            t.Error("this should not have matched the category")
        }
        err = Wrap(err, category, "caterr")
        if !HasCategory(err, category) {
            t.Error("did not match the category")
        }
    }
}

Create a file called caterr_test.go and copy this:

Follow Along

General Guidelines

And other tips

func main(){
    foo() // this hides that an error is possible
    _ := foo()
    baz, _ := bar()
    baz.String() // this will panic because of a nil pointer
}	


type qux struct{
    str string
}

func (q qux) String() { return q.str}



func foo() error {
    return errors.New("")
}

func bar() (*qux, error) {
	return nil, foo()
}

Never squash an error

Errors, like all return variables, can be ignored by using "_". Never do this.

 
func bar(){ //avoid these
    defer foo()
    go foo()
}	


func bar(){
    defer func(){
        if err := foo(); err != nil {
            log.Println(err)
        }
    }()
    
    go func(){
        if err := foo(); err != nil {
            log.Println(err)
        }
    }()
}	

Not even in go/defer funcs

You cannot pass up an error in go/defer functions. They are often ignored, especially in packages. Try to avoid this. You can use anonymous functions to handle them.

 
func main(){
    if err := foo(); err != nil {
        handleError(err)
    }
}	

func foo() error {
    defer func(){
        if err := bar(); err != nil {
            handleError(err)
        }
    }()
    return errors.New("")
}

func handleError(err error) {
    log.Println("[!] " + err.Error()) // decide how you want logs to look
    // maybe increment a metric
    // do something else
}

Decide how to handle

I want a standard configurable function, errors.Handle(error). Imported packages shouldn't have to choose how you handle errors in go/defer functions. Currently, they are often ignored or just logged. If you agree, give my proposal a thumbs up.

Don't just log. Decide how errors should be handled.

func main(){
    err := worstExample

    // these will cause a compile issue because "err" is of another type     
    err := badExample()
    err = goodExample()
}


func worstExample() MyError { //cannot be nil
	return MyError{}
}

func badExample() *MyError {
	return &MyError{}
}

func goodExample() error {
	return &MyError{}
}

Only return "error"

Even if you use another type, function returns should always be of the type error

err := foo()
if err != nil {
    ...
}

if err := foo(); err != nil {
	...
}

if _, err := bar(); err != nil {
   ...
}

baz, err := bar()
if err != nil {
   ...
}

Scoping errors

To avoid "err" escaping the scope of when it is handled use an inline if. 

If you consider this a language bug like I do give my proposal a thumbs up.

 
func main() {
    if err := foo(); err != nil {
        log.Println(err)
    }
}

func foo() error {
    ...
    if err := bar(); err != nil {
        log.Println(err) // bad
        return err
    }
    ...
}

func bar() error {
    ...
    if err := foo(); err != nil {
        log.Println(err) // bad
        return err
    }
    ...
}

Return or Handle

Only handle an error once.

This error will be mistakenly logged 3 times.

 
func foo() error {
    if err := bar(); err != nil {
        return err
    }
    if err != baz(); err != nil {
        return err
    }
    return nil
}

func otherFoo() error {
    if err := bar(); err == nil { // "==" instead of "!="" can be easily missed
        if err != baz(); err != nil {
            return err
        }
        return nil // hidden happy path
    } else {
        return err
    }
    return nil // unreachable code
}

Return quickly/avoid "else"

Returning from functions as soon as you find an error avoids skipping errors. Not using "else" in Go lets the left side of your function be a happy path line of sight.

Q&A

Comments

Go Errors

By Jason Mavandi

Go Errors

More than you every wanted to know about errors

  • 1,394