Capture deferred errors in Go

One of my obsessions is to capture errors diligently. Most often, I satiate my desire with the divisive incantation if err != nil { return fmt.Errorf(...) }. Errors in defer statements require a more delicate touch. Opening a file demands a matching close to avoid leaking file descriptors. For example, we might open a file and write interesting data:

func populateFile() error
	f, err := os.Open("foo.txt", os.O_CREATE, 0o644)
	if err != nil {
		return fmt.Errorf("open file: %w", err)
	}
	defer f.Close() // BAD: ignored error
	
	err = writeInterestingData(f)
	if err != nil {
		return fmt.Errorf("write data: %w", err)
	}
	return nil
}
motivating example

Problematically, we ignore the error when closing the file. The usual remedy is to defer an immediately invoked function expression (IIFE for short, a term our JavaScript friends might recognize). The IIFE overwrites the colloquially-named err return argument.

func populateFile() (err error)
	f, err := os.Open("foo.txt", os.O_CREATE, 0o644)
	if err != nil {
		return fmt.Errorf("open file: %w", err)
	}
	defer func() {
		if closeErr := f.Close(); err != nil {
			// BAD: overwrites existing error
			err = fmt.Errorf("close file: %w", closeErr)
		}
	}()

	err = writeInterestingData(f)
	if err != nil {
		return fmt.Errorf("write data: %w", err)
	}
	return nil
}
overwriting the error in defer

Alas, this solution brings its own set of problems. If f.Close errors, we overwrite the error from writeInterestingData. We need to combine the errors. Before reaching to Uber's multierr package, we'll lean on errors.Join to combine multiple errors, introduced by Go 1.20.

func populateFile() (err error)
	f, err := os.Open("foo.txt", os.O_CREATE, 0o644)
	if err != nil {
		return fmt.Errorf("open file: %w", err)
	}
	defer func() {
		if closeErr := f.Close(); err != nil {
			err = errors.Join(err, fmt.Errorf("close file: %w", closeErr))
		}
	}()

	err = writeInterestingData(f)
	if err != nil {
		return fmt.Errorf("write data: %w", err)
	}
	return nil
}
correct and verbose

The solution does not please the eyes and requires choosing a name other than err, like closeErr for the f.Close error.

Captured by Thanos

Inspired by Thanos' coding style guide, we'll simplify the unwieldy anonymous function. Thanos defines runutil.CloseWithErrCapture, which calls Close and combines the error with an existing named error.

// CloseWithErrCapture closes closer, wraps any error with message from
// fmt and args, and stores this in err.
func CloseWithErrCapture(err *error, c io.Closer, format string, a ...any) {
	merr := errutil.MultiError{}
	merr.Add(*err)
	merr.Add(errors.Wrapf(c.Close(), format, a...))
	*err = merr.Err()
}
runutil.go
helper functions from thanos

Armed by Thanos, we'll replace the anonymous function with CloseWithErrCapture.

func populateFile() (err error)
	f, err := os.Open("foo.txt", os.O_CREATE, 0o644)
	if err != nil {
		return fmt.Errorf("open file: %w", err)
	}
	defer runutil.CloseWithErrCapture(&err, f, "close file")

	err = writeInterestingData(f)
	if err != nil {
		return fmt.Errorf("write data: %w", err)
	}
	return nil
}
correct and less verbose

An err of simplicity

We'll cull half the complexity from CloseWithErrCapture with a snap. While we're at it, we'll generalize the pattern to any error-returning function named errs.Capture. In our 300 kLOC monorepo, we use errs.Capture 554 times. Only 60% of the calls are for io.Closer.Close. The remaining calls are cleanup functions, like Flush, Shutdown, and functions requiring context, like pgx.Conn.Close(ctx).

package errs

import (
	"errors"
	"fmt"
)

// Capture runs errFunc and assigns the error, if any, to *errPtr.
// Preserves the original error by wrapping with errors.Join if
// errFunc returns a non-nil error.
func Capture(errPtr *error, errFunc func() error, msg string) {
	err := errFunc()
	if err == nil {
		return
	}
	*errPtr = errors.Join(*errPtr, fmt.Errorf("%s: %w", msg, err))
}
errs.Capture revealed

Instead of using an io.Closer interface, we'll pass the function method at the call-site.

func populateFile() (err error)
	f, err := os.Open("foo.txt", os.O_CREATE, 0o644)
	if err != nil {
		return fmt.Errorf("open file: %w", err)
	}
	defer errs.Capture(&err, f.Close, "close file")

	err = writeInterestingData(f)
	if err != nil {
		return fmt.Errorf("write data: %w", err)
	}
	return nil
}
corrected motivating example

Our errs.Capture outshines on runutil.CloseWithErrCapture in three ways. First, the name is shorter, more direct, and avoids the bad package name runutil. Second, by generalizing to any error-returning function, we've moved Close out of the implementation and to the call site, removing a layer of indirection. Third, the function stands alone, implemented solely in the standard library.

Extensions

We've considered a few similar capture functions but only implemented errs.CaptureT to capture errors in a test. We call the testing variant 125 times in our 300 kLOC monorepo.

package errs

// testingTB is a subset of *testing.T and *testing.B methods.
type testingTB interface {
	Helper()
	Errorf(format string, args ...interface{})
}

// CaptureT call t.Errorf if errFunc returns an error with a message.
func CaptureT(t testingTB, errFunc func() error, msg string) {
	t.Helper()
	if err := errFunc(); err != nil {
		t.Errorf("%s: %s", msg, err)
	}
}
capture errors in tests

Extensions we haven't implemented since the utility and ubiquity is low:

  • errs.CaptureContext for capturing errors from functions that take a context.Context and return an error. It's not much more code to use errs.Capture with an anonymous function. Only 25 of the 554 calls to errs.Capture need a context.

  • errs.CaptureLog to log the error. We only log deferred errors a handful of times.

  • errs.Capture support for formatted errors. We only used format support a handful of times. The pain of calling fmt.Sprintf didn't outweigh the (minor) complexity of supporting formatted arguments.