Error Handling Options in Go — An example of a Robot Pizza Shop

An ordinary error. Photo by Sarah Kilian on Unsplash

Motivation

Recently I was working on refactoring and developing a long-running worker application written in Go. It will basically loop over a task list and do something. But I found out that the main process will panic and exit if one task encounter some unexpected failure. This is not acceptable because the tasks after the failed one would never get chance to be executed.

Trying to fix the problem, I realised that I don’t really know what error is and what options do I have for error handling. Therefore, I have spent quite sometime to investigate and summarise into this article.

In the article, we will use a context to illustrate error handling in Go: A Robot Pizza Shop!

Robot Pizza Shop

Let me briefly talk about the context we will use.

Imagine you are the owner of a pizza shop which is ran by robot employees. You have to program those robots in order to make pizza.

To make pizza, the robot should use oven to bake pizza. But the oven is provided by third-party. In the programming perspective, it is a third-party library you don’t have control.

What is error in Go?

In the Go Source Code, type error is an interface which then contains merely an Error() method return a string:

type error interface {
Error() string
}

An error variable represents any value that can describe itself as a string.

The Go Blog: Error handling and Go

This specification makes the interaction with error the same as with any other structs.

Define an error

We can simply define our own error. Let’s say, we want to define an error to represent an abnormal event in our pizza shop: insufficient ingredients.

type InsufficientIngredientsError struct {
s string
}
func (e *InsufficientIngredientsError) Error() string {
return e.s
}

To create and return such error:

return &InsufficientIngredientsError{"ananas out of stock"}

Notice that it return a pointer to the error, you should also do that.

Enrich the error

Moreover, we may want to add more information to the error. We can do that to our InsufficientIngredientsError like to any other structs.

type InsufficientIngredientsError struct {
Ingredients []string
}
func (e *InsufficientIngredientsError) Error() string {
return fmt.Sprintf("%s out of stock", strings.Join(e.Ingredients, " "))
}

To create and return such error:

xs := []string{"Ananas", "Pepperoni"}
return &InsufficientIngredientsError{xs}

We only touch the basics of enriching errors here. If you want to know more, check : Effective Go: Errors and Error Handling in Upspin.

What options do we have?

Now let’s see what we can do when we encounter an error.

1. Ignore the error or log it out

Scenario:

  • Ignorable error that does not have any impact on the rest of the program

This is straightforward. In our pizza shop example, we have a background worker which update the order every couple minutes. So if it failed we just ignore it.

func updateOrder() error

If we invoke the function updateOrder and want to ignore the error, we can do this:

updateOrder()

Or maybe we want to log it:

err := updateOrder()
if err != nil {
log.Printf("ERROR: %v", err)
}

By the way, If we don’t care about the concrete error and we are able to define updateOrder ourselves, we can define the signature like this:

func updateOrder() (ok bool)

That is, instead of an error, return a boolean to indicate whether the action was succeeded or not.

To ignore the error in multiple return, we can use _ . Notice that it is a convention to always keep the error as the last return value.

i, _ := strconv.Atoi("a")
fmt.Println(i) // print 0

But as we can see in this example, it will use the default value 0 as the result of integer conversion, which is often a bad practice.

2. Panic without recover, AKA Let it crash

Scenario:

  • Unexpected fatal error
  • Expected fatal error but don’t know how to proceed or cannot do anything. With this error the rest program make no sense (i.e., cannot read initial parameter from environment variable).

Panic is a built-in function that stops the ordinary flow of control and begins panicking. When the function F calls panic, execution of F stops, any deferred functions in F are executed normally, and then F returns to its caller. To the caller, F then behaves like a call to panic. The process continues up the stack until all functions in the current goroutine have returned, at which point the program crashes. Panics can be initiated by invoking panic directly. They can also be caused by runtime errors, such as out-of-bounds array accesses.

The Go Blog: Defer, Panic and Recover

If the program better just crash when an error is encountered, then we should invoke the built-in function panic(err) whereas err is the error. In our pizza robot example, if we failed to turn on the oven (which is from a third-party provider), we may just want to let it crash there, since the rest of the program doesn’t make sense and there is nothing we can do to recover from this error.

err := oven.TurnOn()
if err != nil {
panic(err)
}

3. Panic with recover

Scenario:

  • Recover from runtime panic
  • Advanced control flow

Recover is a built-in function that regains control of a panicking goroutine. Recover is only useful inside deferred functions. During normal execution, a call to recover will return nil and have no other effect. If the current goroutine is panicking, a call to recover will capture the value given to panic and resume normal execution.

(…)

In summary, the defer statement (with or without panic and recover) provides an unusual and powerful mechanism for control flow. It can be used to model a number of features implemented by special-purpose structures in other programming languages.

The Go Blog: Defer, Panic and Recover]

As described in the above, use panic with recover is more an unusual and advanced topic. I choose not to touch that in this article since I never used it so far. For more information, please refer to Effictive Go: Recover.

4. Act based on the error type / customised comparison

Scenario:

  • All-around

We could also choose to do something based on the error. Since errors are really just value, we can program it and conduct actions accordingly. This is really the Go way.

Go 1.13 introduce two handy functions errors.Is and errors.As to examine errors.

In the simplest case, the errors.Is function behaves like a comparison to a sentinel error, and the errors.As function behaves like a type assertion.

The Go Blog: Working with Errors in Go 1.13

Act based on error type

In the following snippet, we conduct different actions based on the error type we get from invoking makePizza , if we get an error with InsufficientIngredientsError type, we buy the missing ingredients and put the order back to the pending order list. Else if we get an error with OvercookedError , we simply put the order back. For other error types, we panic.

err := makePizza(order)
var insufficientIngredientsErr *InsufficientIngredientsError
var overcookedErr *OvercookedError
if errors.As(err, &insufficientIngredientsErr) {
buyIngredients(err.Ingredients)
putOrderBack(order)
} else if errors.As(err, overcookedErr) {
putOrderBack(order)
} else {
panic(err)
}

Act based on error comparison

Let’s say we have RobotError defined like this:

type RobotError struct {
RobotId uint
RobotType string
}
func (e *RobotError) Error() string {
return fmt.Sprintf("robot error: RobotId <%d> RobortType: <%s>", e.RobotId, e.RobotType)
}

Assume we want to differentiate RobotError we encountered only based on the RobotType . That is, we don’t care about RobotId in error comparison.

robotAndriodError := &RobotError{0, "Android"}
robotAppleError := &RobotError{0, "Apple"}
err := bootRobot()
if err != nil {
if errors.Is(err, robotAndriodError) {
orderNewRobot(err.RobotType)
} else if errors.Is(err, robotAppleError) {
callAppleSupport(err.RobotId)
} else {
panic(err)
}
}

To achieve this, we have to define a Is method for our RobotError:

func (e *RobotError) Is(target error) bool {
t, ok := target.(*RobotError)
if !ok {
return false
}
return t.RobotType == e.RobotType
}

You may ask, why make it so complicate? Why not do something like this:

switch err.RobotType {
case "Android":
orderNewRobot(err.RobotType)
case "Apple":
callAppleSupport(err.RobotId)
default:
panic(err)
}

True. But first, usually the function which return the error has type error not the specific RobotError .

func bootRobot() error

In that case, it will not compile:

err.RobotType undefined (type error has no field or method RobotType)

Of course, you can do the type assertion. But I believe it’s better to do it inIs method of the specificRobotError . This makes Is method of a specific error the single point of truth how the comparison for that error should be defined.

Secondly, this approach works well with wrapped errors.

When operating on wrapped errors, however, these functions consider all the errors in a chain.

The Go Blog: Working with Errors in Go 1.13

For wrapped errors, see the next section.

One more thing: wrap and unwrap error

A wrapped error is an error that contains other error.

If e1.Unwrap() returns e2, then we say that e1 wraps e2, and that you can unwrap e1 to get e2.

(…)

In Go 1.13, the fmt.Errorf function supports a new %w verb. When this verb is present, the error returned by fmt.Errorf will have an Unwrap method returning the argument of %w, which must be an error. In all other ways, %w is identical to %v.

(…)

It’s important to remember that whether you wrap or not, the error text will be the same. A person trying to understand the error will have the same information either way; the choice to wrap is about whether to give programs additional information so they can make more informed decisions, or to withhold that information to preserve an abstraction layer.

The Go Blog: Working with Errors in Go 1.13

This seems straightforward. Basically, you can wrap an error in another error by using %w like this:

fmt.Errorf("decompress %v: %w", outerErrorMessage, innerError)

But how to wrap and unwrap for customised error?

Back to our robot pizza shop. Let’s define yet another error:

type InitializationError struct {
Inner error
Message string
}
func (e *InitializationError) Error() string {
return fmt.Sprintf("initialized failed: %s, due to %s", e.Message, e.Inner)
}
func (e *InitializationError) Unwrap() error {
return e.Inner
}

So to initialise our pizza shop, we may encounter some error. We call it InitializationError , but the root cause of this error could be another error. Therefore, we have defined field Inner which is meant to contain the root cause.RobotError we introduced in the last section could be such Inner.

To create and return wrapped error:

return &InitializationError{&RobotError{777, "Android"}, "internal error"}

Notice that we have also defined an Unwrap method for InitializationError . This make errors.As and errors.Is we discussed in the last section work for this wrapped error. You can check the source code to verify that: https://github.com/golang/go/blob/master/src/errors/wrap.go

Whether you should wrap or not, the blog post explains it very well:

It’s important to remember that whether you wrap or not, the error text will be the same. A person trying to understand the error will have the same information either way; the choice to wrap is about whether to give programs additional information so they can make more informed decisions, or to withhold that information to preserve an abstraction layer.

The Go Blog: Working with Errors in Go 1.13

Conclusion

This is my first attempt to write an article on such a big topic. I hope this could inspire you a bit about handling error in Go and point out the direction if you want to dig deeper.

Thank you for reading and stay safe :)

Reference

Grokking Simplicity, where the idea of the robot pizza shop comes from . Other than that, a great book about functional (programming) thinking.

The Go Programming Language Specification, the official language spec every gopher should read through.

The Go Blog: Working with Errors in Go 1.13, explained the new feature of wrapped error

The Go Blog: Error Handling and Go, check this out to know how to simplify repetitive error handling

Effective Go: Errors, tips and examples for writing clear and idiomatic Go

GopherCon 2019: Marwan Sulaiman — Handling Go Errors, check this out if you want to know more about enriching error and logging

Error Handling in Upspin, for enriching errors

Senior Software Engineer @ Instana, Programming / Human Language Enthusiast. Located in Munich, Germany

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store