golang & Error Chains

0. A brief review of Go error handling

Go is a programming language with a strong emphasis on error handling. In Go, errors are represented as values of types that implement the error interface. The error interface has only one method:

1
2
3
type error interface {
    Error() string
}

The introduction of this interface allows Go programs to handle errors in a consistent and idiomatic manner.

One of the challenges of error handling in all programming languages is to be able to provide enough error context information to help the developer diagnose the problem while avoiding the developer being inundated with unnecessary details. In Go, this challenge is currently addressed by the use of error chains.

Note: The official Go user survey results show that the Go community still has high expectations for improvements to the Go error handling mechanism. This is still a considerable challenge for the Go core team. The good thing is that Go 1.18 generic has landed, and as Go generics gradually mature, a more elegant error handling scheme is likely to surface in the near future.

Error chaining is a technique for wrapping one error within another to provide additional context about the error. This technique is particularly useful when errors are propagated through multiple layers of code, with each layer adding its own context to the error message.

However, initially Go’s error handling mechanism did not support error chaining, and Go’s support and refinement of error chaining was something that only started in Go version 1.13.

As you know, in Go, error handling is usually done using the if err ! = nil is the usual way to do it. When a function returns an error, the calling code checks to see if the error is nil. if the error is not nil, it is usually printed to the log or returned to the caller.

For example, look at the following function that reads a file:

1
2
3
4
5
6
7
func readFile(filename string) ([]byte, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        return nil, err
    }
    return data, nil
}

In this code, os.ReadFile() returns an error if reading the file fails. If this happens, the readFile function returns the error to its caller. this basic error handling mechanism of Go is simple, effective and well understood, but it has its own limitations. One of the main limitations is that error messages can be ambiguous. When an error is propagated through multiple layers of code, it may be difficult for a developer to determine the true source and cause of the error. Let’s look at the following code:

1
2
3
4
5
6
7
8
func processFile(filename string) error {
    data, err := readFile(filename)
    if err != nil {
        return err
    }
    // process the file data...
    return nil
}

In this example, if readFile() returns an error, the error message will simply indicate that the file could not be read; it will not provide any accurate information about what caused the error or where it occurred.

Another constraint of Go’s basic error handling is that the context of the error may be lost when the error is processed. In particular, when an error passes through multiple layers of code, one layer may ignore the received error message and instead construct its own error message and return it to the caller, so that the initial error context is lost in the passing of the error, which is not conducive to quick diagnosis of the problem.

So, how do we address these limitations? Below we explore how error chaining can help Go developers solve these limitations.

1. error wrapping and error chaining

To address the limitations of basic error handling, Go provided the Unwrap interface and the %w formatting verb of fmt.Errorf in version 1.13 for building errors that can wrap other errors and for determining whether a given error is present from an error that wraps other errors and extracting error information from it.

Errorf is the most common function used to wrap errors, taking an existing error and wrapping it in a new error with more error context information attached.

For example, transform the example code above.

1
2
3
4
5
6
7
8
func processFile(filename string) error {
    data, err := readFile(filename)
    if err != nil {
        return fmt.Errorf("failed to read file: %w", err)
    }
    // process the file data...
    return nil
}

In this code, fmt.Errorf creates a new error via %w. The new error wraps the original error and appends some error context information (failed to read file). This new error can be propagated through the call stack and provide more context about the error.

To retrieve the original error from the error chain, Go provides the Is, As and Unwrap() functions in the errors package; the Is and As functions are used to determine if an error exists in the error chain, and the Unwrap function returns the next direct error in the error chain.

The following is a complete example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func readFile(filename string) ([]byte, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        return nil, err
    }
    return data, nil
}

func processFile(filename string) error {
    data, err := readFile(filename)
    if err != nil {
        return fmt.Errorf("failed to read file: %w", err)
    }
    fmt.Println(string(data))
    return nil
}

func main() {
    err := processFile("1.txt")
    if err != nil {
        fmt.Println(err)
        fmt.Println(errors.Is(err, os.ErrNotExist))
        err = errors.Unwrap(err)
        fmt.Println(err)
        err = errors.Unwrap(err)
        fmt.Println(err)
        return
    }
}

Running this program (prerequisite: the 1.txt file does not exist) results in the following:

1
2
3
4
5
$go run demo1.go
failed to read file: open 1.txt: no such file or directory
true
open 1.txt: no such file or directory
no such file or directory

The wrap and unwrap relationship of error in the example is as follows:

The wrap and unwrap relationship of error

A chain structure like this, formed by errors wrapped one by one (as shown below), is what we call an error chain .

error chain

Next, let’s talk more about the use of Go error chains.

2. Using error chains in Go

2.1 How to create error chains

As mentioned earlier, we create error chains by wrapping errors.

The APIs currently provided in the Go standard library for wrap errors are fmt.Errorf and errors.Join. fmt.Errorf is most commonly used, as we demonstrated in the example above. errors.Join is used to wrap a set of errors into a single error.

fmt.Errorf also supports wrapping multiple errors at once via multiple %w. The following is a complete example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func main() {
    err1 := errors.New("error1")
    err2 := errors.New("error2")
    err3 := errors.New("error3")

    err := fmt.Errorf("wrap multiple error: %w, %w, %w", err1, err2, err3)
    fmt.Println(err)
    e, ok := err.(interface{ Unwrap() []error })
    if !ok {
        fmt.Println("not imple Unwrap []error")
        return
    }
    fmt.Println(e.Unwrap())
}

The output of the sample run is as follows.

1
2
wrap multiple error: error1, error2, error3
[error1 error2 error3]

We see that multiple errors wrap at once via fmt.Errorf are output on one line after Stringification. This is different from that of errors.Join. Here is an example of wrapping multiple errors at once with errors.Join.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func main() {
    err1 := errors.New("error1")
    err2 := errors.New("error2")
    err3 := errors.New("error3")

    err := errors.Join(err1, err2, err3)
    fmt.Println(err)
    errs, ok := err.(interface{ Unwrap() []error })
    if !ok {
        fmt.Println("not imple Unwrap []error")
        return
    }
    fmt.Println(errs.Unwrap())
}

The output of this example is as follows.

1
2
3
4
5
$go run demo2.go
error1
error2
error3
[error1 error2 error3]

We see that multiple errors wrap at once through errors.Join are Stringified and each error occupies a separate line.

If you are not satisfied with any of the above output formats, then you can also customize the Error type, as long as it implements at least String() string and Unwrap() error or Unwrap() []error.

2.2 Determining whether an error is in the error chain

As mentioned earlier the errors package provides Is and As functions to determine if an error is in the error chain, and both errors.Is and As are available as expected for the case of multiple error values in a single wrap.

2.3 Getting contextual information about a particular error in an error chain

There are times when we need to get the contextual information of a particular error from the error chain, and there are at least two ways to achieve this through the Go standard library:

The first one: unwrap the errors in the error chain one by one by using the errors.Unwrap function.

Since the number of errors in the error chain and the characteristics of each error are uncertain, this is a good way to get the root cause error, i.e. the last error in the error chain. here is an example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func rootCause(err error) error {
    for {
        e, ok := err.(interface{ Unwrap() error })
        if !ok {
            return err
        }
        err = e.Unwrap()
        if err == nil {
            return nil
        }
    }
}

func main() {
    err1 := errors.New("error1")

    err2 := fmt.Errorf("2nd err: %w", err1)
    err3 := fmt.Errorf("3rd err: %w", err2)

    fmt.Println(err3) // 3rd err: 2nd err: error1

    fmt.Println(rootCause(err1)) // error1
    fmt.Println(rootCause(err2)) // error1
    fmt.Println(rootCause(err3)) // error1
}

The second one: the errors.As function extracts a specific type of error from the error chain

The error.As function is used to determine whether an error is a specific type of error, and if so, that error is extracted, e.g:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
type MyError struct {
    err string
}

func (e *MyError) Error() string {
    return e.err
}

func main() {
    err1 := &MyError{"temp error"}
    err2 := fmt.Errorf("2nd err: %w", err1)
    err3 := fmt.Errorf("3rd err: %w", err2)

    fmt.Println(err3)

    var e *MyError
    ok := errors.As(err3, &e)
    if ok {
        fmt.Println(e)
        return
    }
}

In this example, we extract err1 from the error chain err3 to e via errors.As, we can subsequently use the information from err1, a specific error.

3. Summary

Error chaining is an important technique for providing information-rich error information in Go. By wrapping errors with additional context, you can provide more specific information about the error and help developers diagnose the problem faster.

There are some things to keep in mind when using error chains, though, such as: Avoid nested error chains. Nested error chains can make it difficult to debug your code and to understand the root cause of the error.

By combining error chains, by adding context to errors, creating custom error types, and handling errors at the appropriate level of abstraction, you can write clean, readable, and informative error handling code.

4. Ref

  • https://tonybai.com/2023/05/14/a-guide-of-using-go-error-chain/