Handling Go Errors
Marwan Sulaiman
The New York Times
GopherCon, July 2019
github.com/marwan-at-work
@MarwanSulaiman
Background
- Backend Engineer at The New York Times for 2 years
- ~2 YR as a Front End Engineer at Work & Co
- Ruby On Rails Bootcamp at App Academy
- No CS Background 🙃
I'd like to make an assumption
Go was not your first programming language
Why is this important?
Learning concepts
Learning syntax
VS
Learning Syntax
How do I parse a JSON string in Go?
Learning Concepts
What is data serialization?
Learning Syntax
How do I import a library in Go?
Learning Concepts
What are dependencies?
Learning Syntax
How do I catch an error in Go?
Learning Concepts
What is error handling?
Conclusion
Learn like a beginner
The Concept Of Errors
Errors are values
PRO
you get to define the importance of errors
CON
you get to define the importance of errors
1. Errors are i/o
- Sometimes you need to read an error
- Sometimes you need to write an error
Context matters
- Is your program a CLI tool?
- Is your program a library?
- Is your program a long running system?
- Who is consuming your program? And How?
Goal: make a sandwich
package main
import (
"markets/wholefoods"
"markets/traderjoes"
"markets/shoppers"
)
func BuyAvocados() (Ingredient, error)
func BuyEggs() (Ingredient, error)
func BuyBread() (Ingredient, error)
func getIngredients() ([]Ingredient, error) {
avocados, err := wholefoods.BuyAvocados()
boiledEggs, err := traderjoes.BuyEggs()
bread, err := shoppers.BuyBread()
return []Ingredient{avocados, boiledEggs, bread}, nil
}
How do I handle errors in Go?
type error interface {
Error() string
}
func getIngredients() ([]Ingredient, error) {
avocados, err := wholefoods.BuyAvocados()
if err != nil {
return nil, err
}
boiledEggs, err := traderjoes.BuyEggs()
if err != nil {
return nil, err
}
bread, err := shoppers.BuyBread()
if err != nil {
return nil, err
}
return []Ingredient{avocados, boiledEggs, bread}, nil
}
func main() {
ingredients, err := getIngredients()
if err != nil {
panic(err)
}
makeSandwich(ingredients)
}
$ go run main.go
panic: ingredient not available
goroutine 1 [running]:
main.main()
path/to/file/main.go:10 +0x44
exit status 2
which ingredient?
stack trace doesn't show getIngredients()
2. Don't just check errors
- Handle them gracefully
How do I decorate errors in Go?
1
return fmt.Errorf("unique error message: %w", err)
import "github.com/pkg/errors"
return errors.Wrap(err, "unique error message")
2
import "github.com/pkg/errors"
func getIngredients() ([]Ingredient, error) {
avocados, err := wholefoods.BuyAvocados()
if err != nil {
return nil, errors.Wrap(err, "could not buy avocados")
}
boiledEggs, err := traderjoes.BuyEggs()
if err != nil {
return nil, errors.Wrap(err, "could not buy eggs")
}
bread, err := shoppers.BuyBread()
if err != nil {
return nil, errors.Wrap(err, "could not buy bread")
}
return []Ingredient{avocados, boiledEggs, bread}, nil
}
$ go run main.go
panic: could not buy eggs: ingredient not available
goroutine 1 [running]:
main.main()
path/to/file/main.go:10 +0x44
exit status 2
👍🏽
👋🏽
3. Stack traces are for disasters
- They're hard to read
- They're hard to parse
- At best, they say where an error went wrong, and not why
What if we want to act on an error?
import "github.com/pkg/errors"
func getIngredients() ([]Ingredient, error) {
avocados, err := wholefoods.BuyAvocados()
if err != nil {
return nil, errors.Wrap(err, "could not buy avocados")
}
boiledEggs, err := traderjoes.BuyEggs()
if err == tradejoes.ErrNotAvailable {
boiledEggs, err = wholefoods.BuyEggs()
}
if err != nil {
return nil, errors.Wrap(err, "could not buy eggs")
}
bread, err := shoppers.BuyBread()
if err != nil {
return nil, errors.Wrap(err, "could not buy bread")
}
return []Ingredient{avocados, boiledEggs, bread}, nil
}
Takeaways:
- We can handle errors gracefully
- We can trace the error back to the code
- We can act upon an error
Is this enough?
no
Further things you can do
- Categorize errors by severity.
- Categorize errors by type.
- Add application specific data.
- You can query all of the above.
Fast Forward 1 Year
The Architecture
Paper Delivery API
Login/Register API
Subscriptions API
Emails API
Account API
You Are Here!
Account UI
Not that different from making sandwiches
- One service that talks to a bunch of other services
- Instead of panicking, we log and monitor
func getUser(userID string) (Subscription, time.Time, error) {
err := loginService.Validate(userID)
if err != nil {
return err
}
subscription, err := subscriptionService.Get(userID)
if err != nil {
return err
}
deliveryTime, err := deliveryService.GetTodaysDeliveryTime(userID)
if err != nil {
return err
}
return subscription, deliveryTime, nil
}
func getUserHandler(w http.ResponseWriter, r *http.Request) {
// set up handler
sub, deliveryTime, err := getUser(user)
if err != nil {
logger.Error(err)
fmt.Fprint(w, "something went wrong")
return
}
// return info to client
}
Account Is Not Active
User entered wrong password
User abandoned request
Wrong form values
User is not a subscriber
Free Trial Expired
Refactor Goals:
-
Can filter unexpected errors
-
Group by error types
-
Be able to answer specific questions
Looking for inspirations
Your Domain
Service A
Database
Client
3rd party lib
Service B
type DB interface {
Get(id string) (*Record, error)
}
// Implementations: SQL, FS, MongoDB
func ForgotPassword(userID string, db DB) error {
record, err := db.Get(userID)
isNotFounnd := err == sql.ErrNoRows ||
os.IsNotExist(err) ||
mongo.ErrNoDocuments
if isNotFounnd {
return NotFoundError
}
// ...
}
package errors
package errors
type Error struct {
}
package errors
type Error struct {
Op Op // operation
}
package errors
type Error struct {
Op Op // operation
Kind Kind // category of errors
}
package errors
type Error struct {
Op Op // operation
Kind Kind // category of errors
Err error // the wrapped error
}
package errors
type Error struct {
Op Op // operation
Kind Kind // category of errors
Err error // the wrapped error
//... application specific fields
}
How do we construct an Error type?
if err != nil {
return &errors.Error{
Op: "getUser",
Err: err,
}
}
package errors
func E(args ...interface{}) error {
e := &Error{}
for _, arg := range args {
switch arg := arg.(type) {
case Op:
e.Op = arg
case error:
e.Err = arg
case Kind:
e.Kind = arg
default:
panic("bad call to E")
}
}
return e
}
if err != nil {
return errors.E(op, err, errors.KindUnexpected)
}
type Op string
What is an Operation?
-
A unique string describing a method or a function
-
Multiple operations can construct a friendly stack trace.
// app/account/account.go
package account
func getUser(userID string) (*User, err) {
const op errors.Op = "account.getUser"
err := loginService.Validate(userID)
if err != nil {
return nil, errors.E(op, err)
}
...
}
// app/login/login.go
package login
func Validate(userID string) err {
const op errors.Op = "login.Validate"
err := db.LookUpUser(userID)
if err != nil {
return nil, errors.E(op, err)
}
}
// app/errors/errors.go
package errors
// Ops returns the "stack" of operations
// for each generated error.
func Ops(e *Error) []Op {
res := []Op{e.Op}
subErr, ok := e.Err.(*Error)
if !ok {
return res
}
res = append(res, Ops(subErr)...)
return res
}
["account.GetUser", "login.Validate", "db.LookUp"]
errors.Ops stack trace
goroutine 19 [running]:
net/http.(*conn).serve.func1(0xc0000928c0)
/usr/local/go/src/net/http/server.go:1746 +0xd0
panic(0x12459c0, 0x12eb450)
/usr/local/go/src/runtime/panic.go:513 +0x1b9
db.LookUp(...)
/Users/208581/go/src/app/db/lookup.go:25
login.Validate(...)
/Users/208581/go/src/app/login/login.go:21
account.getUser(...)
/Users/208581/go/src/app/account/account.go:17
main.getUserHandler(0x12ef4e0, 0xc000118000, 0xc000112000)
/Users/208581/go/src/app/account/account.go:17
net/http.HandlerFunc.ServeHTTP(0x12bc838, 0x12ef4e0, 0xc000118000, 0xc000112000)
/usr/local/go/src/net/http/server.go:1964 +0x44
net/http.(*ServeMux).ServeHTTP(0x149f720, 0x12ef4e0, 0xc000118000, 0xc000112000)
/usr/local/go/src/net/http/server.go:2361 +0x127
net/http.serverHandler.ServeHTTP(0xc000098d00, 0x12ef4e0, 0xc000118000, 0xc000112000)
/usr/local/go/src/net/http/server.go:2741 +0xab
net/http.(*conn).serve(0xc0000928c0, 0x12ef6e0, 0xc00009e240)
/usr/local/go/src/net/http/server.go:1847 +0x646
created by net/http.(*Server).Serve
/usr/local/go/src/net/http/server.go:2851 +0x2f5
Classic stack trace
Benefits of errors.Op
-
A custom stack of your code only
- Easier to read
- Parsable and queryable.
- Not only can you know where something went wrong, but the impact it had on your entire application.
Query your stack trace
SELECT * FROM logs WHERE operations.include("login.Validate")
["account.getUser"],
["account.resetPassword"],
["homeDelivery.changeAddress"]
One final thought on Op
func helperFunction(op errors.Op, userID string) error {
if somethingGoesWrong() {
return errors.E(op, "something went wrong")
}
}
- You can make your stack even simpler by removing helper functions from the stack.
Kind
const (
KindNotFound = http.StatusNotFound
KindUnauthorized = http.StatusUnauthorized
KindUnexpected = http.StatusUnexpected
)
- Groups all errors into smaller categories
- Can be predefined codes (http/gRPC)
- Or it can be your own defined codes
Extracting a Kind from an error
func Kind(err error) codes.Code {
e, ok := err.(*Error)
if !ok {
return KindUnexpected
}
if e.Kind != 0 {
return e.Kind
}
return Kind(e.Err)
}
If your kind is an http status, then you can propagate an error back to the client
func getUserHandler(w http.ResponseWriter, r *http.Request) {
// set up handler
sub, deliveryTime, err := getUser(user)
if err != nil {
logger.Error(err)
http.Error(w, "something went wrong", errors.Kind(err))
return
}
// return info to client
}
type Error struct {
...
Severity logrus.Level
}
The Severity Of Your Errors
func getUser(userID string) (*User, err) {
const op errors.Op = "account.getUser"
err := loginService.Validate(userID)
if err != nil {
return nil, errors.E(op, err, logrus.InfoLevel)
}
...
}
func getUserHandler(w http.ResponseWriter, r *http.Request) {
// set up handler
sub, deliveryTime, err := getUser(user)
if err != nil {
logger.SystemErr(err)
http.Error(w, "something went wrong", errors.Kind(err))
return
}
// return info to client
}
func SystemErr(err error) {
sysErr, ok := err.(*errors.Error)
if !ok {
logger.Error(err)
return
}
entry := logger.WithFields(
"operations", errors.Ops(sysErr),
"kind", errors.Kind(err),
// application specific data
)
switch errors.Level(err) {
case Warning:
entry.Warnf("%s: %v", sysErr.Op, err)
case Info:
entry.Infof("%s: %v", sysErr.Op, err)
case Debug:
entry.Debugf("%s: %v", sysErr.Op, err)
default:
entry.Errorf("%s: %v", sysErr.Op, err)
}
}
Application Specific Data
type Error struct {
// ... op, kind, etc
Artist, Song string
}
type Error struct {
// ... op, kind, etc
ZipCode string
}
type Error struct {
// ... op, kind, etc
From, To string
}
type Error struct {
// ... op, kind, etc
OrderedAt time.Time
RestaurantID string
}
Questions you can answer:
- Show me all paper delivery errors in zip code 22434
- Show me all food delivery errors by seafood restaurants
- Show me all errors that happened while trying to stream the latest Beyonce album
Takeaways:
-
The error interface is intentionally simple.
-
Design an errors package that makes sense to your application, and no one else
Conclusion
A big part of all programming, for real, is how you handle errors - Rob Pike
Thank You
Handling Go Errors (GopherCon)
By marwansameer
Handling Go Errors (GopherCon)
- 1,083