Skip to content

Latest commit

 

History

History

errors

errors PkgGoDev

General purpose error handling package with few extra bells and whistles for HTTP interop.

Table of contents

Usage

import "github.com/sudo-suhas/xgo/errors"

Creating 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

Adding context to an error

A new error wrapping the original error is returned by passing it in to the constructor. Additional context can include the following (optional):

if err := svc.SaveOrder(o); err != nil {
	return errors.E(errors.WithOp(op), errors.WithErr(err))
}

Inspecting errors

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 for the end user

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.

HTTP interop

Status code

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.

Response body

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

Logging errors

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.

Errors package objectives

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