Building Microservices in Go: REST APIs: Implementing and Dealing with errors

When building any software, specifically Microservices, there's something we should always be aware of, something that is going to happen no matter what: errors.

Failures are always something we have to consider when building new software products, it's part of the territory and there's no way around it, specially when building distributed systems.

The problem is not failing but rather the lack of planning regarding monitoring and reacting to those failures.

Introduction to errors

Errors in Go are simple in a sense any type implementing the error interface is considered an error, the idea with errors is to detect them, do something with them and if needed bubble them up so the callers can also do something with them:

if err := function(); err != nil {
  // something happens
  return err
}

In Go 1.13 a few extra methods were added to the errors package to handle identifying and working errors in a better way, specifically:

Instead of comparing a sentinel error using the == operator we can use something like:

if err == io.ErrUnexpectedEOF // Before
if errors.Is(err, io.ErrUnexpectedEOF) // After

Instead of explicitly do the type assertion we can use this function:

if e, ok := err.(*os.PathError); ok // Before

var e *os.PathError // After
if errors.As(err, &e)
  • New fmt verb %w and errors.Unwrap, with the idea of decorating errors with more details but still keeping the original error intact. For example:
fmt.Errorf("something failed: %w", err)

This errors.Unwrap function is going to make more sense when looking at the code implemented below.

Implementing a custom error type with state

The code used for this post is available on Github.

It looks like this:

// Error represents an error that could be wrapping another error, it includes a code for determining
// what triggered the error.
type Error struct {
    orig error
    msg  string
    code ErrorCode
}

And the supported error codes:

const (
    ErrorCodeUnknown ErrorCode = iota
    ErrorCodeNotFound
    ErrorCodeInvalidArgument
)

With those types we can define a few extra functions to help us wrap the original errors, for example our PostgreSQL repository, uses WrapErrorf to wrap the error and add extra details regarding what happend:

return internal.Task{}, internal.WrapErrorf(err, internal.ErrorCodeUnknown, "insert task")

Then if this error happens, the HTTP layer can react to it and render a corresponding response with the right status code:

func renderErrorResponse(w http.ResponseWriter, msg string, err error) {
    resp := ErrorResponse{Error: msg}
    status := http.StatusInternalServerError

    var ierr *internal.Error
    if !errors.As(err, &ierr) {
        resp.Error = "internal error"
    } else {
        switch ierr.Code() {
        case internal.ErrorCodeNotFound:
            status = http.StatusNotFound
        case internal.ErrorCodeInvalidArgument:
            status = http.StatusBadRequest
        }
    }

    renderResponse(w, resp, status)
}

Conclusion

The idea of defining your own errors is to consolidate different ways to handle them, adding state to them allows us to react differently; in our case it would be about rendering different responses depending on the code; but maybe in your use case it could main triggering different alerts or sending messages to different services.

21