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.