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:
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:
panic, defer, recover
The try/catch of go
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered:", r)
}
}()
panic("panicking")
}
// Recovered: panickingExample
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
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.
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.
// 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.
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