Since go 1.13, go has gone one step further in terms of error handling. Errors can be wrapped, bubbled up and act upon.
We will here take a step back on error handling in go, from the perspective of a library.
What data could we have in the error?
Inspired by a rust talk from Jan Lusby about errors, here are a few things that error could contain:
- The reason of the error
- Context of the error: struct field, struct surrounding values…
- Stack trace: which part of the code triggered this error
- How to fix the error: suggest a way to fix it
Where are errors displayed?
- In the logs of an application
- In front of the end user
Ways to define errors after golang 1.13
a) inline, with no type definition
return fmt.Errorf("bad int %d", i)
(+)
- simple to write
- no runtime penalty
(-)
- impossible to handle properly as you can’t match and react accordingly
b) sentinel errors
var errBadInt = errors.New("bad int")
return fmt.Errorf("%w %d", i)
(+)
- match on error possible
(-)
- runtime (init) penalty
- binary size penalty
- global vars (fragile)
c) structs, with defined context and string formatting
type errBadInt struct {
i int
}
func (e *errBadInt) Error() string {
return fmt.Errorf("bad int: %d", i)
}
func newErrBadInt(i int) *errBadInt {
return &errBadInt{i}
}
More tricks used by libs out there
upspin by Rob Pike himself
- smart use of const and redefine the Error struct with context
- return stack in debug mode
big cache
- interesting usage of const + error interface to avoid global vars
So what should we do?
For simple errors
Keep the errors as they are, but turn them into const thanks to the bigcache trick. It keeps the nice opportunity to use errors.Is and removes the initialization cost. No wrapping when there is no additional context. Context is not stack trace. Externally available errors should be handling all errors, unwrap them and repackage them again.
type marshalError string
func (me marshalError) Error() string {
return string(me)
}
const ErrBadInt = marshalError("bad int")
func testErr() error {
return fmt.Errorf("%w", ErrBadInt)
}
For more complex errors
type badValueError struct {
key string
value interface{}
}
func (bv *badValueError) Error() string {
return fmt.Sprintf("bad value %v for key %s", bv.value, bv.key)
}
func testErr() error {
v := 3
k := "test"
return badValueError{key: k, value: v}
}
Handling errors
Error as values
package main
import (
"errors"
"fmt"
"log"
)
type marshalError string
func (me marshalError) Error() string {
return string(me)
}
const errBadInt = marshalError("bad int")
func testErr() error {
return fmt.Errorf("%w", errBadInt)
}
func main() {
err := testErr()
if errors.Is(err, errBadInt) {
log.Printf("%v", err)
} else {
log.Println("wrong")
}
var e marshalError
if errors.As(err, &e) {
log.Printf("%v", err)
} else {
log.Println("wrong")
}
}
Error as struct
package main
import (
"errors"
"fmt"
"log"
)
type badValueError struct {
key string
value interface{}
}
func (bv *badValueError) Error() string {
return fmt.Sprintf("bad value %v for key %s", bv.value, bv.key)
}
func testErr() error {
v := 3
k := "test"
return &badValueError{key: k, value: v}
}
func main() {
err := testErr()
var e *badValueError
if errors.As(err, &e) {
log.Printf("%v", err)
} else {
log.Println("wrong")
}
}
Further thoughts
Panic or not panic?
I tend to agree with uber recommendation to not panic in a library, especially when you’re expected to most likely handle user data. It allows the user of the lib to do whatever they want with the returned error.
That said, it’s important to actually notify the library user if the library is used improperly in cases that are supposedly “not possible” by panicking. The lib user is always able to handle these panics through a recover.
Wrapping or not wrapping?
from go1.13 post on errors:
"wrapping an error makes that error part of your API. If you don't want to commit to supporting that error as part of your API in the future, you shouldn't wrap the error."
It also makes no sense to wrap unexported errors to the external user of the API, as the user has no way of testing/matching the error. External methods hence should not return internal errors and return their own exported type, catered at the lib user.
See what wrapping allow and not allow you to do:
package main
import (
"errors"
"fmt"
)
type AlphaError struct {
err error
ratio int
}
func (e *AlphaError) Error() string {
return fmt.Sprintf("%v %d", e.err, e.ratio)
}
type BetaError struct {
err error
key string
}
func (e *BetaError) Error() string {
return fmt.Sprintf("%v %s", e.err, e.key)
}
func (e *BetaError) Unwrap() error {
return e.err
}
type GammaError struct {
name string
}
func (e *GammaError) Error() string {
return fmt.Sprintf("%s", e.name)
}
func main() {
err1 := &AlphaError{err: &GammaError{name: "pollux"}, ratio: 1}
err2 := &BetaError{err: &GammaError{name: "pollux"}, key: "123"}
var (
alphaError *AlphaError
betaError *BetaError
gammaError *GammaError
)
if errors.As(err1, &alphaError) { // true
fmt.Println("alpha is alpha")
}
if errors.As(err1, &gammaError) { // false
fmt.Println("alpha is gamma")
}
if errors.As(err2, &betaError) { // true
fmt.Println("beta is beta")
}
if errors.As(err2, &gammaError) { // true
fmt.Println("beta is gamma")
}
}
Never inline errors?
Simple cases don’t require to define sentinel values and a simple string is often enough to start with. If later on, you need to match on the error value or type, you can change this to an actual sentinel value.
That said, I think any error part of your API should be typed, as you want the lib user to be able to handle it according to its need.