2020-12-10

Decorating Go Errors

Go is powerful and popular — projects such as Kubernetes were implemented in Go. One interesting property of Go is that its multi-value returns capability provides a different error handling approach than other programming languages. Go treats the error as a value with a predefined type, technically an interface. However, writing a multi-layered architecture application and exposing the features with APIs demands error treatment with much more contextual information than just a value. Here we will explore how we can decorate the Go error type to bring in more value in the application.

Custom Type

As we will override the default Go error type we have to start with a custom error type which will be interpreted within the application, and is also of Go error type. Hence we will introduce new custom error interface composing the Go error:

type GoError struct { error }

Contextual Data

When we say the error is a value in Go, it is of string value - any type which has the Error() string function implemented qualifies for the error type. Treating string values as errors complicates the error interpretations across the layers, as interpreting the error string is not the right approach. So let’s decouple the string with the error code:

type GoError struct { error Code string }

Now the error interpretation will be based on the error Code rather than the string. Let’s further decouple the error string with the contextual data which allows internationalization with thei18N package

type GoError struct { error Code string Data map[string]interface{} }

Data contains the contextual data to construct the error string. The error string can be templatized with the data:

//i18N def "InvalidParamValue": "Invalid parameter value '{{.actual}}', expected '{{.expected}}' for '{{.name}}'"

In the i18N definition file, the error Code will be mapped with the templatized error string, which will be constructed using the Data values.

Cause

The error can occur in any layer and it is necessary to provide the option for each layer to interpret the error and further wrap with additional contextual information without losing the original error value. The GoError can be further decorated with the Causes which will hold the entire error stack.

type GoError struct { error Code string Data map[string]interface{} Causes []error }

Causes is an array type if it has to hold multiple error data and is set to the base error type to include the third-party error for the cause within the application.

Component

Tagging the layer component will help to identify the layer where the error has occurred, and unnecessary error wraps can be avoided. For example, if the error component of servicetype occurs in the service layer, then the wrapping error might not be required. Component information checks will help to prevent exposing the errors which a user shouldn’t be informed about, like Database errors:

type GoError struct { error Code string Data map[string]interface{} Causes []error Component ErrComponent } type ErrComponent string const ( *ErrService *ErrComponent = "service" *ErrRepo *ErrComponent = "repository" *ErrLib *ErrComponent = "library" )

Response Type

Adding an error response type will support the error categorization for easy interpretation. For example, the errors can be categorized with response types like NotFound, and this information can be used for errors like DbRecordNotFound , ResourceNotFound , UserNotFound, and so on. This is useful during multi-layered application development and is an optional decoration:

type GoError struct { error Code string Data map[string]interface{} Causes []error Component ErrComponent ResponseType ResponseErrType } type ResponseErrType string const ( *BadRequest *ResponseErrType = "BadRequest" *Forbidden *ResponseErrType = "Forbidden" *NotFound *ResponseErrType = "NotFound" *AlreadyExists *ResponseErrType = "AlreadyExists" )

Retry

In a few cases, the errors will be retried. The retry component can decide whether to retry for the error by having the flag Retryable:

type GoError struct { error Code string Message string Data map[string]interface{} Causes []error Component ErrComponent ResponseType ResponseErrType Retryable bool }

GoError Interface

Error checking can be simplified by defining an explicit error interface definition with the implementation of GoError:

package goerr type Error interface { error Code() string Message() string Cause() error Causes() []error Data() map[string]interface{} String() string ResponseErrType() ResponseErrType SetResponseType(r ResponseErrType) Error Component() ErrComponent SetComponent(c ErrComponent) Error Retryable() bool SetRetryable() Error }

Error Abstraction

With the above-mentioned decorations, it is important to build the abstraction over an error and keep these decorations in a single place and provide the reusability of the error function:

func ResourceNotFound(id, kind string, cause error) GoError { data := map[string]interface{}{"kind": kind, "id": id} return GoError{ Code: "ResourceNotFound", Data: data, Causes: []error{cause}, Component: *ErrService*, ResponseType: *NotFound*, Retryable: false, } }

This error function abstracts the ResourceNotFound and the developer will use this function instead of constructing the new error object every time:

//UserService user, err := u.repo.FindUser(ctx, userId) if err != nil { if err.ResponseType == *NotFound *{ return ResourceNotFound(userUid, "User", err) } return err }

Conclusion

We demonstrated how to use the custom Go error type with added contextual data to make the error more meaningful in a multi-layered application. Take a look at the complete code with proper interface definition and implementation.