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.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 toerrs.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.