Raphael Ugwu Writer, Software Engineer and a lifelong student.

Error handling in Golang

7 min read 2069

Unlike conventional methods in other mainstream programming languages such as JavaScript (which uses the try… catch statement) or Python (with its try… except block) tackling errors in Go requires a different approach. Why? Because its features for error handling are often misapplied.

In this blog post, we’ll take a look at the best practices that could be used to handle errors in a Go application. A basic understanding of how Go works is all that is required to digest this article – should you feel stuck at some point, it’s okay to take some time and research unfamiliar concepts.

The blank identifier

The blank identifier is an anonymous placeholder. It may be used like any other identifier in a declaration, but it does not introduce a binding. The blank identifier provides a way to ignore left-handed values in an assignment and avoid compiler errors about unused imports and variables in a program. The practice of assigning errors to the blank identifier instead of properly handling them is unsafe as this means you have decided to explicitly ignore the value of the defined function.

result, _ := iterate(x,y)

if value > 0 {
  // ensure you check for errors before results.
}

Your reason for probably doing this is that you’re not expecting an error from the function (or whatever error may occur) but this could create cascading effects in your program. The best thing to do is to handle an error whenever you can.

Handling errors through multiple return values

One way to handle errors is to take advantage of the fact that functions in Go support multiple return values. Thus you can pass an error variable alongside the result of the function you’re defining:

func iterate(x, y int) (int, error) {

}

In the code sample above, we have to return the predefined error variable if we think there’s a chance our function may fail. error is an interface type declared in Go’s built-in package and its zero value is nil.

type error interface {
   Error() string
 }

Usually, returning an error means there’s a problem and returning nil means there were no errors:

result, err := iterate(x, y)
 if err != nil {
  // handle the error appropriately
 } else {
  // you're good to go
 }

Thus whenever the function iterate is called and err is not equal to nil, the error returned should be handled appropriately – an option could be to create an instance of a retry or cleanup mechanism. The only drawback with handling errors this way is that there’s no enforcement from Go’s compiler, you have to decide on how the function you created returns the error. You can define an error struct and place it in the position of the returned values. One way to do this is by using the built-in errorString struct (you can also find this code at Go’s source code):

package errors

 func New(text string) error {
     return &errorString {
         text
     }
 }

 type errorString struct {
     s string
 }

 func(e * errorString) Error() string {
     return e.s
 }

In the code sample above, errorString embeds a string which is returned by the Error method. To create a custom error, you’ll have to define your error struct and use method sets to associate a function to your struct:

// Define an error struct
type CustomError struct {
    msg string
}
// Create a function Error() string and associate it to the struct.
func(error * CustomError) Error() string {
    return error.msg
}
// Then create an error object using MyError struct.
func CustomErrorInstance() error {
    return &CustomError {
        "File type not supported"
    }
}

The newly created custom error can then be restructured to use the built-in error struct:

 import "errors"
func CustomeErrorInstance() error {
    return errors.New("File type not supported")
}

One limitation of the built-in error struct is that it does not come with stack traces. This makes locating where an error occurred very difficult. The error could pass through a number of functions before it gets printed out. To handle this, you could install the pkg/errors package which provides basic error handling primitives such as stack trace recording, error wrapping, unwrapping, and formatting. To install this package, run this command in your terminal:

go get github.com/pkg/errors

When you need to add stack traces or any other information that makes debugging easier to your errors, use the New or Errorf functions to provide errors that record your stack trace. Errorf implements the fmt.Formatter interface which lets you format your errors using the fmt package runes (%s, %v, %+v etc):

import(
    "github.com/pkg/errors"
    "fmt"
)
func X() error {
    return errors.Errorf("Could not write to file")
}

func customError() {
    return X()
}

func main() {
    fmt.Printf("Error: %+v", customError())
}

To print stack traces instead of a plain error message, you have to use %+v  instead of %v in the format pattern, and the stack traces will look similar to the code sample below:

Error: Could not write to file
main.X
 /Users/raphaelugwu/Go/src/golangProject/error_handling.go:7
main.customError
 /Users/raphaelugwu/Go/src/golangProject/error_handling.go:15
main.main
 /Users/raphaelugwu/Go/src/golangProject/error_handling.go:19
runtime.main
 /usr/local/opt/go/libexec/src/runtime/proc.go:192
runtime.goexit
 /usr/local/opt/go/libexec/src/runtime/asm_amd64.s:2471

Defer, panic, and recover

Although Go doesn’t have exceptions, it has a similar kind of mechanism known as “Defer, panic, and recover“. Go’s ideology is that adding exceptions such as the try/catch/finally statement in JavaScript would result in complex code and encourage programmers to label too many basic errors, such as failing to open a file, as exceptional. You should not use defer/panic/recover as you would throw/catch/finally; only in cases of unexpected, unrecoverable failure.

Defer is a language mechanism that puts your function call into a stack. Each deferred function is executed in reverse order when the host function finishes regardless of whether a panic is called or not. The defer mechanism is very useful for cleaning up resources:

package main

import (
        "fmt"
)

func A() {
        defer fmt.Println("Keep calm!")
        B()
}
func B() {
        defer fmt.Println("Else...")
        C()
}
func C() {
        defer fmt.Println("Turn on the air conditioner...")
        D()
}
func D() {
        defer fmt.Println("If it's more than 30 degrees...")
}
func main() {
        A()
}

This would compile as:

If it's more than 30 degrees...
Turn on the air conditioner...
Else...
Keep calm!

Panic is a built-in function that stops the normal execution flow. When you call panic in your code, it means you’ve decided that your caller can’t solve the problem. Thus panic should only be used in rare cases where it’s not safe for your code or anyone integrating your code to continue at that point. Here’s a code sample depicting how panic works:

package main

import (
        "errors"
        "fmt"
)

func A() {
        defer fmt.Println("Then we can't save the earth!")
        B()
}
func B() {
        defer fmt.Println("And if it keeps getting hotter...")
        C()
}
func C() {
        defer fmt.Println("Turn on the air conditioner...")
        Break()
}
func Break() {
        defer fmt.Println("If it's more than 30 degrees...")
        panic(errors.New("Global Warming!!!"))

}
func main() {
        A()
}

The sample above would compile as:

If it's more than 30 degrees...
Turn on the air conditioner...
And if it keeps getting hotter...
Then we can't save the earth!
panic: Global Warming!!!

goroutine 1 [running]:
main.Break()
        /tmp/sandbox186240156/prog.go:22 +0xe0
main.C()
        /tmp/sandbox186240156/prog.go:18 +0xa0
main.B()
        /tmp/sandbox186240156/prog.go:14 +0xa0
main.A()
        /tmp/sandbox186240156/prog.go:10 +0xa0
main.main()
        /tmp/sandbox186240156/prog.go:26 +0x20

Program exited: status 2.

As shown above, when panic is used and not handled, the execution flow stops, all deferred functions are executed in reverse order and stack traces are printed.

You can use the recover built-in function to handle panic and return the values passing from a panic call. recover must always be called in a defer function else it will return nil:

package main

import (
        "errors"
        "fmt"
)

func A() {
        defer fmt.Println("Then we can't save the earth!")
        defer func() {
                if x := recover(); x != nil {
                        fmt.Printf("Panic: %+v\n", x)
                }
        }()
        B()
}
func B() {
        defer fmt.Println("And if it keeps getting hotter...")
        C()
}
func C() {
        defer fmt.Println("Turn on the air conditioner...")
        Break()
}
func Break() {
        defer fmt.Println("If it's more than 30 degrees...")
        panic(errors.New("Global Warming!!!"))

}
func main() {
        A()
}

As can be seen in the code sample above, recover prevents the entire execution flow from coming to a halt because we threw in a panic function and the compiler would return:

If it's more than 30 degrees...
Turn on the air conditioner...
And if it keeps getting hotter...
Panic: Global Warming!!!
Then we can't save the earth!

Program exited.

To report an error as a return value, you have to call the recover function in the same goroutine as the panic function is called, retrieve an error struct from the recover function, and pass it to a variable:

package main

import (
        "errors"
        "fmt"
)

func saveEarth() (err error) {

        defer func() {
                if r := recover(); r != nil {
                        err = r.(error)
                }
        }()
        TooLate()
        return
}
func TooLate() {
        A()
        panic(errors.New("Then there's nothing we can do"))
}

func A() {
        defer fmt.Println("If it's more than 100 degrees...")
}
func main() {
        err := saveEarth()
        fmt.Println(err)
}

Every deferred function will be executed after a function call but before a return statement. So, you can set a returned variable before a return statement gets executed. The code sample above would compile as:

If it's more than 100 degrees...
Then there's nothing we can do

Program exited.

Error wrapping

Previously error wrapping in Go was only accessible via using packages such as pkg/errors. However, with Go’s latest release – version 1.13, support for error wrapping is present. According to the release notes:

An error e can wrap another error w by providing an Unwrap method that returns w. Both e and w are available to programs, allowing e to provide additional context to w or to reinterpret it while still allowing programs to make decisions based on w.

To create wrapped errors, fmt.Errorf now has a %w verb and for inspecting and unwrapping errors, a couple of functions have been added to the error package:

errors.Unwrap: This function basically inspects and exposes the underlying errors in a program. It returns the result of calling the Unwrap method on Err. If Err’s type contains an Unwrap method returning an error. Otherwise, Unwrap returns nil.

package errors

type Wrapper interface{
  Unwrap() error
}

Below is an example implementation of the Unwrap method:

func(e*PathError)Unwrap()error{
  return e.Err
}

errors.Is: With this function, you can compare an error value against the sentinel value. What makes this function different from our usual error checks is that instead of comparing the sentinel value to one error, it compares it to every error in the error chain. It also implements an Is method on an error so that an error can post itself as a sentinel even though it’s not a sentinel value.

func Is(err, target error) bool

In the basic implementation above, Is checks and reports if err or any of the errors in its chain are equal to target (sentinel value).

errors.As: This function provides a way to cast to a specific error type. It looks for the first error in the error chain that matches the sentinel value and if found, sets the sentinel value to that error value and returns true:

package main

import (
        "errors"
        "fmt"
        "os"
)

func main() {
        if _, err := os.Open("non-existing"); err != nil {
                var pathError *os.PathError
                if errors.As(err, &pathError) {
                        fmt.Println("Failed at path:", pathError.Path)
                } else {
                        fmt.Println(err)
                }
        }

}

You can find this code in Go’s source code.

Compiler result:

Failed at path: non-existing

Program exited.

An error matches the sentinel value if the error’s concrete value is assignable to the value pointed to by the sentinel value. As will panic if the sentinel value is not a non-nil pointer to either a type that implements error or to any interface type. As returns false if err is nil.

Summary

The Go community has been making impressive strides as of late with support for various programming concepts and introducing even more concise and easy ways to handle errors. Have you got any ideas on how to handle or work with errors that may appear in your Go program? Do let me know in the comments below.

Resources:
Go’s programming language specification on Type assertion
Marcel van Lohuizen’s talk at dotGo 2019 – Go 2 error values today
Go 1.13 release notes

 

 

 

Plug: , a DVR for web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Raphael Ugwu Writer, Software Engineer and a lifelong student.

Leave a Reply