Error Handling on Golang

Before learning the Go language, I believed that Python was the most convenient language for providing error and exception handling for programmers; after learning the Go language, my opinion has not changed at all…

@DGideas

This article references @ethancai’s article and @davecheney‘s keynote speech at Gocon Spring 2016, as well as several blog posts and documents on golang.org.

The Go language offers a unique error handling experience compared to other programming languages. One of the distinctive features of the Go programming language is the frequent occurrence of the if err != nil statement block. Thanks to the multiple return values feature of Go functions, a function that may not always execute successfully can use an error type return value to indicate whether its execution process is in an abnormal state, for example:

f, err := os.Open("filename.ext")
if err != nil {
	log.Fatal(err)
}

This passing mechanism helps Go language programmers, especially function designers, convey to the function’s caller whether the function call meets expectations without overloading the meaning of the function’s return results themselves. In fact, the designers of the Go language believe that using try-catch-finally blocks for error and exception handling would lead to code verbosity. Requiring programmers to explicitly handle errors at the language level helps improve the robustness of the program.

To handle errors in Go language effectively, it’s essential to understand more about the error type.

What is error?

In the code found in src/builtin/builtin.go, we notice that the language itself defines the error type as follows:

// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
	Error() string
}

These statements indicate that errors are just values. Experienced Go language programmers tell us that it signifies errors are represented simply as values. The language implements error as an interface type, meaning any type that implements this error interface itself belongs to the error type.

The New() method provided by the standard library errors makes it easy for us to define new error types. For example, the standard library io includes a custom error type io.EOF, which indicates encountering the end of a file during reading (EOF). The definition of io.EOF is as follows:

var EOF = errors.New("EOF")

With custom error types, we gain the ability to distinguish what kind of error occurred. Below is an error handling method based on error type detection:

buf := make([]byte, 100)
n, err := r.Read(buf)
buf = buf[:n]
if err == io.EOF {
	log.Fatal("read failed:", err)
}

Just like except EOFError: in Python, the above statement only catches one type of error, io.EOF. This approach isn’t very flexible. If upstream modifies the logic of the Read() function to return a different error type, such as io.END or any other type, then all error handling logic under the calls to r.Read() would need to be modified.

Speaking of which, we might come up with various ideas to circumvent this issue of type changes. Among many methods, the most unreliable approach is to rely on the text returned by err.Error() to determine the type of error:

f, err := os.Open("filename.ext")
if strings.Contains(err.Error(), "not found") {
	log.Fatal(err)
}

The string returned by Error() in the error type is designed for human users, as error descriptions can change at any time. In your career, never rely on string-based error type identification. We’ll see a more elegant error handling method below.

Two pieces of advice…

There are two more commonly used methods for handling errors:

  1. As function designers, we can define custom error types within the function, allowing callers of higher-level functions to discern error types through type assertions.
  2. Alternatively, we can simply check if an error occurred (err != nil), without knowing any details about the error returned by the called function.

Each of these methods has its own pros and cons, which we’ll discuss next.

Defining custom error types within the called function may appear similar to the approach discussed in the previous section regarding io.EOF. However, because we define our own error types, we can attach additional contextual information to the error value:

type MyError struct {
	Msg string
	File string
	Line int
}

func (e *MyError) Error() string {
	return fmt.Sprintf("%s:%d: %s", e.File, e.Line. e.Msg)
}

return &MyError{"Something happened", "server.go", 42}

This example from @davecheney‘s sample code demonstrates customizing an error type capable of preserving contextual information such as file names and line numbers. When error messages are propagated, even when the top-level function prints error logs, it can accurately pinpoint which part of which file encountered the error. There’s a similar joke in the Go community: in a complex network access program with layers of error propagation, finally, at the top level, the error message is printed out:

No such file or directory.

Clearly, this kind of error message isn’t helpful for debugging. However, frameworks like github.com/pkg/errors provide functions such as Wrap() and Cause() to wrap errors layer by layer and pass them to the top-level function. This way, the information printed by the top-level function contains sufficient context for debugging needs:

func Wrap(cause error, message string) error
func Cause(err error) error

After using the Wrap() and Cause() functions, the error printout looks something like this:

could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory

Right, that looks much better!

Programmers need to understand that any approach to inferring errors based on error types will increase dependencies between modules. When handling errors, if we only care about whether an error occurred or not, without concerning ourselves with the error type, we can use the if err != nil form, which is often the first error handling method beginners learn. As the upper-level code, all you need to know is whether the called function is working properly. If you accept this principle, it will greatly reduce the coupling between modules.

Sometimes, we’re not interested in the specific type of error, but rather whether the error implements a certain behavior. For example, the error type net.Error defined in the standard library net is like this:

type Error interface {
	error
	Timeout() bool   // Is the error a timeout?
	Temporary() bool // Is the error temporary?
}

In tasks like network communication, if an error occurs, it’s necessary to understand the attributes of the error to decide whether retrying or other actions are needed:

type temporary interface {
	Temporary() bool    // IsTemporary returns true if err is temporary.
}

func IsTemporary(err error) bool {
	te, ok := err.(temporary)
	return ok && te.Temporary()
}

The advantage of this implementation is that you don’t need to know the specific error type, and thus, you don’t need to reference additional libraries that define error types. If you’re a developer of lower-level code, if one day you want to replace an error implementation with a better one, you don’t have to worry about affecting the logic of upper-level code. If you’re a developer of upper-level code, you only need to focus on whether the error implements specific behavior, without worrying about program logic failing after upgrading referenced third-party libraries.