akavel's digital garden

Don’t return err in Go

Instead, add missing details relevant for debugging.

Some people like to complain that Go requires writing tons of “if err != nil { return err }” blocks. Those people don’t understand Go errors. The thing is, what they complain about is actually a completely wrong way of handling errors in Go: return err is an antipattern.

Let me show what I mean on some sample code: a helper library for configuring an mTLS connection. (“Mutual TLS” is a way to prove to a server that a client is who they claim to be.)

package mtls

import (
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"os"
)

type ClientConfig struct {
	CAPath   string
	KeyPath  string
	CertPath string
}

func (c *ClientConfig) BuildTLSConfig() (*tls.Config, error) {
	if *c == (ClientConfig{}) {
		return nil, fmt.Errorf("mtls: cannot build tls.Config from empty ClientConfig")
	}

	ret := &tls.Config{}
	if c.CAPath != "" {
		ca, err := os.ReadFile(c.CAPath)
		if err != nil {
			return nil, err // FIXME: BAD, antipattern
		}
		pool := x509.NewCertPool()
		pool.AppendCertsFromPEM(ca)
		ret.RootCAs = pool
	}
	if c.KeyPath != "" || c.CertPath != "" {
		cert, err := tls.LoadX509KeyPair(c.CertPath, c.KeyPath)
		if err != nil {
			return nil, err // FIXME: BAD, antipattern
		}
		ret.Certificates = []tls.Certificate{cert}
	}
	return ret, nil
}

With this poor example of error handling, what will be printed if we pass an invalid path “bad-cert.pem” in ClientConfig.CAPath?

ERROR: open bad-cert.pem: no such file or directory

Coming from a big codebase, this will be somewhat informative, but not much so. A lot of debugging would be needed to find out where exactly this error happened. Still, notice one thing: the standard library’s os.ReadFile() function tried to help us a bit here: it added the name of the bad-cert.pem file in the error message. This is a detail that would definitely be helpful for us in debugging. Can we be inspired by this behavior? Can we add some more details that would be helpful in debugging?

@@ -22,7 +22,7 @@ func (c *ClientConfig) BuildTLSConfig() (*tls.Config, error) {
        if c.CAPath != "" {
                ca, err := os.ReadFile(c.CAPath)
                if err != nil {
-                       return nil, err // FIXME: BAD, antipattern
+                       return nil, fmt.Errorf("mtls: building tls.Config from ClientConfig.CAPath: %w", err)
                }
                pool := x509.NewCertPool()
                pool.AppendCertsFromPEM(ca)
@@ -31,7 +31,7 @@ func (c *ClientConfig) BuildTLSConfig() (*tls.Config, error) {
        if c.KeyPath != "" || c.CertPath != "" {
                cert, err := tls.LoadX509KeyPair(c.CertPath, c.KeyPath)
                if err != nil {
-                       return nil, err // FIXME: BAD, antipattern
+                       return nil, fmt.Errorf("mtls: building tls.Config from ClientConfig.KeyPath & .CertPath: %w", err)
                }
                ret.Certificates = []tls.Certificate{cert}
        }

With this improved error handling code, what will be printed if we pass an invalid path “bad-cert.pem” in ClientConfig.CAPath?

ERROR: mtls: building tls.Config from ClientConfig.CAPath: open bad-cert.pem: no such file or directory

Proponents of exceptions may say, “this is so much manual writing labor, exception stack trace would automate that!” This is somewhat true. However, if looking at the manual labor as an investment, there is a couple advantages to Go’s approach over exceptions:

If we need to programatically detect a “file not found” error here, we can do it nicely with errors.Is, thanks to usage of %w in fmt.Errorf above:

if errors.Is(err, fs.ErrNotExist) {
	fmt.Println("err is File Not Found!")
}

For detecting more complex errors programatically, errors.As is the correct approach. If we want to generate such detectable errors, we will need to start defining our own error types instead of just using fmt.Errorf.

For more on error handling in Go, I recommend the “Learn Error Handling” page on the Go wiki.

Appendix: Redacted real-life production code with complex error handling

The code below is taken verbatim from production code written at one of my past employers, with important parts redacted out to make it feasible for publishing.

The fragment showcases complex error handling, and how one can add context that will be very useful when debugging.

Note: “libzzz” is a replacement for the original name of the package, which was the name of the specific protocol being handled by the library.

func (b *Bus) readAndUnpack() ([]byte, error) {
	n, err := io.ReadAtLeast(b.port, b.buf[:], 2)
	got := b.buf[:n]
	if err != nil {
		return nil, newError("libzzz: cannot read 2-byte preamble [got: % 02X] - error: %w", got, err)
	}
	if got[0] != magic_number {
		return nil, newError("libzzz: bad MAGIC NUMBER in response - message starts with: [% 02X] (expected XX...)", got)
	}
	length := int(got[1])
	if length < 5 {
		return nil, newError("libzzz: response too short: len=%d < 5 [% 02X]", length, got)
	}

	// Now that we know the total length, we can read the remaining bytes of the response
	if n < length {
		n, err = io.ReadAtLeast(b.port, b.buf[n:], length-n)
		if err != nil {
			return nil, newError("libzzz: cannot read remaining bytes of a packet [prefix=% 02X][rest=% 02X] - error: %w",
				got, b.buf[len(got):][:n], err)
		}
		got = b.buf[:len(got)+n]
	}
	crc := crc(got[:length-2])
	if crc != [2]byte{got[length-2], got[length-1]} {
		return nil, newError("libzzz: bad CRC [% 02X], expected [...% 02X]", got, crc[:])
	}

	payload := append([]byte(nil), got[2:length-2]...)
	return payload, nil
}

Don’t return err in Go. Instead, add missing details relevant for debugging.

💬 Discuss.

🌳 ripe — contents of this article got classified among complete works that I have edited and published as a cohesive whole. They are similar to a traditional blog post published at a point in time in that way—though I still tend these over time.
© Mateusz Czapliński 🐘 Mastodon 🐙 GitHub 🎮 Itch.io ♟️ BGG 🧶 Ravelry