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) }err = writeInterestingData(f) defer f.Close()// BAD: ignored error if err !=nil {return fmt.Errorf("write data: %w" , err) }return nil }
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 () (errerror ) 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 }
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 () (errerror ) 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 }
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, formatstring , a ...any) { merr := errutil.MultiError{} merr.Add(*err) merr.Add(errors.Wrapf(c.Close(), format, a...)) *err = merr.Err() }
Armed by Thanos, we'll replace the anonymous function with CloseWithErrCapture.
func populateFile () (errerror ) f, err := os.Open("foo.txt" , os.O_CREATE, 0o644)if err !=nil {return fmt.Errorf("open file: %w" , err) }err = writeInterestingData(f) defer runutil.CloseWithErrCapture(&err, f,"close file" )if err !=nil {return fmt.Errorf("write data: %w" , err) }return nil }
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 errsimport ("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 , errFuncfunc ()error , msgstring ) { err := errFunc()if err ==nil {return } *errPtr = errors.Join(*errPtr, fmt.Errorf("%s: %w" , msg, err)) }
Instead of using an io.Closer interface, we'll pass the function method at the
call-site.
func populateFile () (errerror ) f, err := os.Open("foo.txt" , os.O_CREATE, 0o644)if err !=nil {return fmt.Errorf("open file: %w" , err) }err = writeInterestingData(f) defer errs.Capture(&err, f.Close,"close file" )if err !=nil {return fmt.Errorf("write data: %w" , err) }return nil }
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 testingTBinterface { Helper() Errorf(formatstring , args ...interface {}) }// CaptureT call t.Errorf if errFunc returns an error with a message. func CaptureT (t testingTB, errFuncfunc ()error , msgstring ) { t.Helper()if err := errFunc(); err !=nil { t.Errorf("%s: %s" , msg, err) } }
Extensions we haven't implemented since the utility and ubiquity is low:
-
errs.CaptureContextfor 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 toerrs.Captureneed a context. -
errs.CaptureLogto log the error. We only log deferred errors a handful of times. -
errs.Capturesupport 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.