Error handling in Go HTTP applications

Nate Finch had a nice blog post on error flags recently, and it caused me to think about error handling in my own greenfield Go project at work.

Much of the Go software I write follows a common pattern: an HTTP JSON API fronting some business logic, backed by a data store of some sort. When an error occurs, I typically want to present a context-aware HTTP status code and an a JSON payload containing an error message. I want to avoid 400 Bad Request and 500 Internal Server Errors whenever possible, and I also don’t want to expose internal implementation details or inadvertently leak information to API consumers.

I’d like to share the pattern I’ve settled on for this type of application.

An API-safe error interface

First, I define a new interface that will be used throughout the application for exposing “safe” errors through the API:

package app

type APIError interface {
    // APIError returns an HTTP status code and an API-safe error message.
    APIError() (int, string)
}

Common sentinel errors

In practice, most of the time there are a limited set of errors that I want to return through the API. Things like a 401 Unauthorized for a missing or invalid API token, or a 404 Not Found when referring to a resource that doesn’t exist in the data store. For these I create a create a private struct that implements APIError:

type sentinelAPIError struct {
    status int
    msg    string
}

func (e sentinelAPIError) Error() string {
    return e.msg
}

func (e sentinelAPIError) APIError() (int, string) {
    return e.status, e.msg
}

And then I publicly define common sentinel errors:

var (
    ErrAuth      = &sentinelAPIError{status: http.StatusUnauthorized, msg: "invalid token"}
    ErrNotFound  = &sentinelAPIError{status: http.StatusNotFound, msg: "not found"}
    ErrDuplicate = &sentinelAPIError{status: http.StatusBadRequest, msg: "duplicate"}
)

Wrapping sentinels

The sentinel errors provide a good foundation for reporting basic information through the API, but how can I associate real errors with them? ErrNoRows from the database/sql package is never going to implement my APIError interface, but I can leverage the error wrapping functionality introduced in Go 1.13.

One of the lesser-known features of error wrapping is the ability to write a custom Is method on your own types. This is perhaps because the implementation is privately hidden within the errors package, and the package documentation doesn’t give much information about why you’d want to use it. But it’s a perfect fit for these sentinel errors.

First, I define a sentinel-wrapped error type:

type sentinelWrappedError struct {
    error
    sentinel *sentinelAPIError
}

func (e sentinelWrappedError) Is(err error) bool {
    return e.sentinel == err
}

func (e sentinelWrappedError) APIError() (int, string) {
    return e.sentinel.APIError()
}

This associates an error from elsewhere in the application with one of my predefined sentinel errors. A key thing to note here is that sentinelWrappedError embeds the original error, meaning its Error method returns the original error’s message, while implementing APIError with the sentinel’s API-safe message. The Is method allows for comparisons of these wrapping errors with the sentinel errors using errors.Is.

Then I need a public function to do the wrapping:

func WrapError(err error, sentinel *sentinelAPIError) error {
    return sentinelWrappedError{error: err, sentinel: sentinel}
}

(If you wanted to include additional context in the APIError, such as a resource name, this would be a good place to add it.)

When other parts of the application encounter an error, they wrap the error with one of the sentinel errors. For example, the database layer might have its own wrapError function that looks something like this:

package db

import "example.com/app"

func wrapError(err error) error {
    switch {
    case errors.Is(err, sql.ErrNoRows):
        return app.WrapError(err, app.ErrNotFound)
    case isMySQLError(err, codeDuplicate):
        return app.WrapError(err, app.ErrDuplicate)
    default:
        return err
    }
}

Because the wrapper implements Is against the sentinel, you can compare errors to sentinels regardless of what the original error is:

err := db.DoAThing()
switch {
case errors.Is(err, ErrNotFound):
    // do something specific for Not Found errors
case errors.Is(err, ErrDuplicate):
    // do something specific for Duplicate errors
}

Handling errors in the API

The final task is to handle these errors and send them safely back through the API. In my api package, I define a helper function that takes an error and serializes it to JSON:

package api

import "example.com/app"

func JSONHandleError(w http.ResponseWriter, err error) {
    var apiErr app.APIError
    if errors.As(err, &apiErr) {
        status, msg := apiErr.APIError()
        JSONError(w, status, msg)
    } else {
        JSONError(w, http.StatusInternalServerError, "internal error")
    }
}

(The elided JSONError function is the one responsible for setting the HTTP status code and serializing the JSON.)

Note that this function can take any error. If it’s not an APIError, it falls back to returning a 500 Internal Server Error. This makes it safe to pass unwrapped and unexpected errors without additional care.

Because sentinelWrappedError embeds the original error, you can also log any error you encounter and get the original error message. This can aid debugging.

An example

Here’s an example HTTP handler function that generates an error, logs it, and returns it to a caller.

package api

func exampleHandler(w http.ResponseWriter, r *http.Request) {
    // A contrived example that always throws an error.  Imagine this
    // is actually a function that calls into a data store.
    err := app.WrapError(fmt.Errorf("user ID %q not found", "archer"), app.ErrNotFound)
    if err != nil {
        log.Printf("exampleHandler: error fetching user: %v", err)
        JSONHandleError(w, err)
        return
    }

    // Happy path elided...
}

Hitting this endpoint will give you this HTTP response:

HTTP/1.1 404 Not Found
Content-Type: application/json

{"error": "not found"}

And send to your logs:

exampleHandler: error fetching user: user ID "archer" not found

If I had forgotten to call app.WrapError, the response instead would have been:

HTTP/1.1 500 Internal Server Error
Content-Type: application/json

{"error": "internal error"}

But the message to the logs would have been the same.

Impact

Adopting this pattern for error handling has reduced the number of error types and scaffolding in my code – the same problems that Nate experienced before adopting his error flags scheme. It’s centralized the errors I expose to the user, reduced the work to expose appropriate and consistent error codes and messages to API consumers, and has an always-on safe fallback for unexpected errors or programming mistakes. I hope you can take inspiration to improve the error handling in your own code.