Wrapping Multiple Errors in Golang

  • YC YC
  • |
  • 24 January 2023
post-thumb

Error handling and management have always been a hot topic for debate in Golang. Software developers coming from different programming background has their own opinionated view of how it should be done. Many packages have been born to suggest the “correct” way of handling errors, such as pkg/errors.

In Go 1.13, the Go team has launched the official way on how errors should be use. However this did not stop the debate as we have seen endless new proposals for error handling. There were also gaps left to be filled as old problems such as wrapping multiple errors are not addressed by the official library. In similar fashion, there are packages aim to deal with multi errors such as go-multierror by Hashicorp and multierr package by Uber

In Go 1.20 this gap will be filled as the Go team finally release the official way to handle multiple errors.

Wrapping errors

Wrapping errors allows us to embed errors into another error. It allows us to add additional information to the error while retaining the original error. Since Go 1.13, we can do the following to wrap the error.

import (
	"errors"
	"fmt"
)

var notFound = errors.New("NotFound")

func CheckFileExist(filename string) error {
	return fmt.Errorf("failed to find file %s: %w", filename, notFound)
}

Notice the format verb %w? By specifying %w instead of %v, we are wrapping the error so the original error can still be obtained by unwrapping it when necessary.

Why do we add information to errors?

Now suppose you want to move 2 files together to a new directory

import (
	"fmt"
	"os"
)

func MoveBothFiles(firstFileSrc, firstFileDest, secFileSrc, secFileDest string) error {
	// Move the first file from src to dest
	if err := os.Rename(firstFileSrc, firstFileDest); err != nil {
		return fmt.Errorf("failed to move first file from %s to %s: %w", firstFileSrc, firstFileDest, err)
	}

	// Move the second file from src to dest
	if err := os.Rename(secFileSrc, secFileDest); err != nil {
		return fmt.Errorf("failed to move second file from %s to %s: %w", secFileSrc, secFileDest, err)
	}

	return nil
}

By adding additional information, you are able to differentiate the failure of the move operation occurs on which file while preserving the original cause of the error from the os package.

Multiple Errors

Since we want to move both files, the function should ensure that both files are move successfully or none of them are moved. So we revert the move operation of the first file should the operation of the second file fails.

import (
	"fmt"
	"os"
)

func MoveBothFiles(firstFileSrc, firstFileDest, secFileSrc, secFileDest string) error {
	// Move the first file from src to dest
	if err := os.Rename(firstFileSrc, firstFileDest); err != nil {
		return fmt.Errorf("failed to move first file from %s to %s: %w", firstFileSrc, firstFileDest, err)
	}

	// Move the second file from src to dest
	if err := os.Rename(secFileSrc, secFileDest); err != nil {
		// Revert the move operation of the first file
		if revertErr := os.Rename(firstFileDest, firstFileSrc); err != nil {
			err = fmt.Errorf("failed to revert first file %v: %w", revertErr, err)
		}

		return fmt.Errorf("failed to move second file from %s to %s: %w", secFileSrc, secFileDest, err)
	}

	return nil
}

Are you able to spot the problem we have now with wrapping errors? In this line:

err = fmt.Errorf("failed to revert first file %v: %w", revertErr, err)

You can see that we have multiple errors, but we are only able to wrap and preserve one error. This is because before Go 1.20, fmt.Errorf() is able to support only one occurrence of %w format verb. The error formatted with %s will no longer be tracked and normalized as only an additional information.

Go 1.20

With Go 1.20, we now can handle multiple errors and wrap both the errors.

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

func MoveBothFiles(firstFileSrc, firstFileDest, secFileSrc, secFileDest string) error {
	// Move the first file from src to dest
	if err := os.Rename(firstFileSrc, firstFileDest); err != nil {
		return fmt.Errorf("failed to move first file from %s to %s: %w", firstFileSrc, firstFileDest, err)
	}

	// Move the second file from src to dest
	if err := os.Rename(secFileSrc, secFileDest); err != nil {
		// Revert the move operation of the first file
		if revertErr := os.Rename(firstFileDest, firstFileSrc); err != nil {
			err = fmt.Errorf("failed to revert first file %w: %w", revertErr, err)
		}

		return fmt.Errorf("failed to move second file from %s to %s: %w", secFileSrc, secFileDest, err)
	}

	return nil
}

func main() {
	err := MoveBothFiles(
		"/path/to/first/src",
		"/path/to/first/dest",
		"/path/to/second/src",
		"/path/to/second/dest",
	)
	// This will check if one of the errors is due to syscall.EEXIST
	if errors.Is(err, syscall.EEXIST) {
		// Do something...
	}
	// This will find the first error in the error tree that matches os.*PathError
	var pathError *os.PathError
	if errors.As(err, &pathError) {
		// Do something...
	}
}

By having multiple %w, the function fmt.Errorf() will instead create a list of errors. Underneath the hood, it will return error[] instead of error. Unwrapping such error would returns the list error[], allowing the inspection of every wrapped errors.

Wrapping multiple errors without additional information

If there is no desire to add additional information, we can easily wrap all errors using the newly introduced function errors.Join(). This is demonstrated by the official go documentation

import (
	"errors"
	"fmt"
)

func main() {
	err1 := errors.New("err1")
	err2 := errors.New("err2")
	err := errors.Join(err1, err2)
	fmt.Println(err)
	if errors.Is(err, err1) {
		fmt.Println("err is err1")
	}
	if errors.Is(err, err2) {
		fmt.Println("err is err2")
	}
}
comments powered by Disqus

You May Also Like