General purpose error handling package with few extra bells and whistles for HTTP interop.
import "github.com/sudo-suhas/xgo/errors"
To facilitate error construction, the package provides a function,
errors.E
which build an *Error
from its
(functional) options:
func E(opt Option, opts ...Option) error
In typical use, calls to errors.E
will arise multiple times within
a method, so a constant called op
could be defined that will be passed to all
E
calls within the method:
func (b *binder) Bind(r *http.Request, v interface{}) error {
const op = "binder.Bind"
if err := b.Decode(r, v); err != nil {
return errors.E(errors.WithOp(op), errors.InvalidInput, errors.WithErr(err))
}
if err := b.Validate.Struct(v); err != nil {
return errors.E(errors.WithOp(op), errors.InvalidInput, errors.WithErr(err))
}
return nil
}
The error string, for the example above, would look something like this:
binder.Bind: invalid input: json: cannot unmarshal number into Go struct field .Name of type string
A new error wrapping the original error is returned by passing it in to the constructor. Additional context can include the following (optional):
errors.WithOp(string)
: Operation name being attempted.errors.Kind
: Error classification.errors.WithText(string)
: Error string.errors.WithTextf(string, ...interface{})
can also be used to format the error string with additional arguments.errors.WithData(interface{})
: Arbitrary value which could be considered relevant to the error.
if err := svc.SaveOrder(o); err != nil {
return errors.E(errors.WithOp(op), errors.WithErr(err))
}
The error Kind
can be extracted using the function
errors.WhatKind(error)
:
if errors.WhatKind(err) == errors.NotFound {
// ...
}
With this, it is straightforward for the app to handle the error appropriately depending on the classification, such as a permission error or a timeout error.
There is also errors.Match
which can be useful in tests to
compare and check only the properties which are of interest. This allows to
easily ignore the irrelevant details of the error.
func Match(template, err error) bool
The function checks whether the error is of type
*errors.Error
, and if so, whether the fields within equal
those within the template. The key is that it checks only those fields that are
non-zero in the template, ignoring the rest.
if errors.Match(errors.E(errors.WithOp("service.MakeBooking"), errors.PermissionDenied), err) {
// ...
}
The functions errors.Is
and errors.As
, which were
introduced in Go 1.13, can also be used. These functions are
defined in the package for the sake of convenience and delegate to the standard
library. This way, importing both this package and the errors
package from the
standard library should not be necessary and avoids having to work around the
name conflict on importing both.
// For an error returned from the the database client
// return errors.E(errors.WithOp(op), errors.WithErr(err))
if errors.Is(err, sql.ErrNoRows) {
// ...
}
// In the API client
var terr interface{ Timeout() bool }
if errors.As(err, &terr) && terr.Timeout() {
// ...
}
Errors have multiple consumers, the end user being one of them. However, to the end user, the error text, root cause etc. are not relevant (and in most cases, should not be exposed as it could be a security concern).
To address this, Error
has the field UserMsg
which is
intended to be returned/shown to the user.
errors.WithUserMsg(string)
stores the message into the
aforementioned field. And it can be retrieved using
errors.UserMsg
func UserMsg(err error) string
Example:
// CreateUser creates a new user in the system.
func (s *Service) CreateUser(ctx context.Context, user *myapp.User) error {
const op = "svc.CreateUseer"
// Validate username is non-blank.
if user.Username == "" {
msg := "Username is required"
return errors.E(errors.WithOp(op), errors.InvalidInput, errors.WithUserMsg(msg))
}
// Verify user does not already exist
if s.usernameInUse(user.Username) {
msg := "Username is already in use. Please choose a different username."
return errors.E(errors.WithOp(op), errors.AlreadyExists, errors.WithUserMsg(msg))
}
// ...
}
// Elsewhere in the application, for responding with the error to the user
if msg := errors.UserMsg(err); msg != "" {
// ...
}
Translating the error to a meaningful HTTP response is convered in the section HTTP interop - Response body.
errors
is intended to be a general purpose error handling package. However, it
includes minor extensions to make working with HTTP easier. The idea is that its
there if you need it but doesn't get in the way if you don't.
errors.Kind
is composed of the error code and the HTTP status
code.
type Kind struct {
Code string
Status int
}
Rationale for the design of Kind
is documented in
decision-log.md
The Kind
variables defined in the errors
package include mapping to the appropriate HTTP status
code. For example, InvalidInput
maps to 400: Bad Request
while NotFound
maps to 404: Not Found
.
Therefore, provided an error has an appropriate Kind
associated, it is really simple to translate the same to the HTTP status code
using errors.StatusCode
func StatusCode(err error) int
Conversely, errors.KindFromStatus
can be used to
translate the HTTP response code from an external REST API to the
Kind
.
func KindFromStatus(status int) Kind
The utility function errors.WithResp(*http.Response)
,
which uses KindFromStatus
, makes it possible to
construct an error from *http.Response
with contextual information of the
request and response.
Another concern of web applications is returning a meaningful response in case
there is an error. *Error
implements the
xgo.JSONer
interface which can be leveraged for building the
error response.
var ej xgo.JSONer
if errors.As(err, &ej) {
j := ej.JSON()
// ...
}
For example, consider the following error:
msg := "The requested resource was not found."
errors.E(errors.WithOp("Get"), errors.NotFound, errors.WithUserMsg(msg))
This produces the following JSON representation:
{
"code": "NOT_FOUND",
"error": "not found",
"msg": "The requested resource was not found."
}
If needed, this behaviour can easily be tweaked by using
errors.WithToJSON(errors.JSONFunc)
. It expects a function
of type errors.JSONFunc
type JSONFunc func(*Error) interface{}
Simple example illustrating the usage of
errors.WithToJSON
:
func makeErrToJSON(lang string) errors.JSONFunc {
return func(e *errors.Error) interface{} {
msgKey := errors.UserMsg(e)
return map[string]interface{}{
"code": errors.WhatKind(e).Code,
"title": i18n.Title(lang, msgKey),
"message": i18n.Message(lang, msgKey),
}
}
}
// In the HTTP handler, when there is an error, wrap it and pass it to the
// response formatter.
errors.E(errors.WithToJSON(makeErrToJSON(lang)), errors.WithErr(err))
It is recommended to pass in the JSONFunc
to the error only once; i.e the
error should not be wrapped repeatedly with virtually the same function passed
into errors.WithToJSON
. The reason is that functions are
not comparable in Go and hence cannot be de-duplicated for keeping the error
chain as short as possible. The obvious exception is when we want to override
any ToJSON
which might have been supplied in calls to errors.E
further down the stack
Logs are meant for a developer or an operations person. Such a person understands the details of the system and would benefit from being to see as much information related to the error as possible. This includes the logical stack trace (to help understand the program flow), error code, error text and any other contextual information associated with the error.
The error string by default includes most of the information but would not be ideal for parsing by machines and is therefore less friendly to structured search.
*Error.Details()
constructs and yields the details of the
error, by traversing the error chain, in a structure
which is suitable to be parsed.
type InternalDetails struct {
Ops []string `json:"ops,omitempty"`
Kind Kind `json:"kind,omitempty"`
Error string `json:"error"`
Data interface{} `json:"data,omitempty"`
}
With respect to stack traces, dumping a list of every function in the call stack from where an error occurred can many times be overwhelming. After all, for understanding the context of an error, only a small subset of those lines is needed. A logical stack trace contains only the layers that we as developers find to be important in describing the program flow.
- Composability: Being able to compose an error using a different error and optionally associate additional contextual information/data.
- Classification: Association of error type/classification and being able to test for the same.
- Flexibility: Create errors with only the information deemed necessary, almost everything is optional.
- Interoperability:
- HTTP: Map errors classification to HTTP status code to encompass the response code in the error.
- Traits: Define and use traits such as
GetKind
,StatusCoder
to allow interoperability with custom error definitions.
- Descriptive: Facilitate reporting the sequence of events, with relevant
contextual information, that resulted in a problem.
Carefully constructed errors that thread through the operations in the system can be more concise, more descriptive, and more helpful than a simple stack trace.