Build awareness and adoption for your software startup with Circuit.

Error Management in Go - Part II

A primer on how to define, generate, and handle errors in Go

Story so far…

In the previous post we saw what is there and what’s missing in go and how I wanted to improve things for myself and others when it comes to error generation and handling.

We learned about how we could define constants in a simple way and implement the error interface for it so that we have a standard way of writing errors. Now we saw that this approach still lacks the ability to directly log errors in a human-readable form instead of constant values. I can understand that this can still be done by defining our constants as strings but we require our error comparisons to be quick so using strings and comparing them adds some time there.

Error Lifecycle

The error lifecycle in any program/application defines the quality of your code. The easier it is to identify and debug your code the easier it is to solve issues. Following are the stages in an error lifecycle.

Error Generation

You should be able to generate errors with maximum context in a simple standard way so that consumers of your module can inspect and handle them easily. This includes defining the exact errors that you would return and what needs to be as a mitigation step in your spec.

Error Inspection

It should be easy to inspect an error so that it can be handled easily. This includes being able to check if an error is of some category and matches an exact error in the list of possible errors that a function would return.

Error Formatting

One should be able to easily print/log the errors generated in human-readable form with maximum context.

Error Handling

You should not restrict how the consumer handles the errors you emit but allow them to handle errors the way they want to handle them. Error handling is very much required and the generator of an error can suggest mitigations but there is no way to enforce them.

The ohno project

The ohno project has 2 parts one is a generator that generates boilerplate code from your constants to easily satisfy the error interface and also adds additional context as an optional feature. The second part is a library that helps with error inspection and unwrapping.

Generator (ohnogen)

As we saw in the previous post, the need for generating errors in a standard way from a set of enums is present. As mentioned earlier the stringer tool is a very capable one and I chose to modify the stringer tool to generate boilerplate code for error generation. The name of this tool is ohnogen.

This tool takes an enum go file with annotated comments and generates the string representation and the error interface method for the const type.

We first define a custom type like the one below:

type MyFabulousError int
const (
 NotFound      MyFabulousError = 100 + iota // I didn't find what you were looking for!
 AlreadyExists                              // I have this already!
 Internal                                   // Its not you, its me :(
 Unknown                                    // I don't know what happened
 Busy                                       // I'm busy rn, can we do this later?
 Unauthorised                               // You ain't got the creds to do this
 Fatal                                      // Help!!! Im dying!!!
)

When we use the ohnogen command it generates the following boilerplate

package usage_without_ohno

import "strconv"

func _() {
 // An "invalid array index" compiler error signifies that the constant values have changed.
 // Re-run the stringer command to generate them again.
 var x [1]struct{}
 _ = x[NotFound-100]
 _ = x[AlreadyExists-101]
 _ = x[Internal-102]
 _ = x[Unknown-103]
 _ = x[Busy-104]
 _ = x[Unauthorised-105]
 _ = x[Fatal-106]
}

const (
 _MyFabulousError_name      = "NotFoundAlreadyExistsInternalUnknownBusyUnauthorisedFatal"
 _MyFabulousError_desc_name = "I didn't find what you were looking for!I have this already!Its not you, its me :(I don't know what happenedI'm busy rn, can we do this later?You ain't got the creds to do thisHelp!!! Im dying!!!"
)

var (
 _MyFabulousError_index      = [...]uint8{0, 8, 21, 29, 36, 40, 52, 57}
 _MyFabulousError_desc_index = [...]uint8{0, 40, 60, 82, 108, 142, 176, 195}
)

// Returns the error name as string
func (i MyFabulousError) String() string {
 i -= 100
 if i < 0 || i >= MyFabulousError(len(_MyFabulousError_index)-1) {
  return "MyFabulousError(" + strconv.FormatInt(int64(i+100), 10) + ")"
 }
  
 return _MyFabulousError_name[_MyFabulousError_index[i]:_MyFabulousError_index[i+1]]
}

// Returns the description string
func (i MyFabulousError) Description() string {
 i -= 100
 if i < 0 || i >= MyFabulousError(len(_MyFabulousError_desc_index)-1) {
  return "MyFabulousError(" + strconv.FormatInt(int64(i+100), 10) + ")"
 }
  
 return _MyFabulousError_desc_name[_MyFabulousError_desc_index[i]:_MyFabulousError_desc_index[i+1]]
}

// Returns the error's string representation
// [CODE]PACKAGE_NAME.ERROR_NAME: DESCRIPTION
func (i MyFabulousError) Error() string {
 return "[" + i.Code() + "]" + i.Package() + "." + i.String() + ": " + i.Description()
}

// Returns the package name
func (i MyFabulousError) Package() string {
 return "usage_without_ohno"
}

// Returns the integer code string as per the format base provided
func (i MyFabulousError) Code() string {
 return "0x" + strconv.FormatInt(int64(i), 16)
}

This boilerplate generator ohnogen has options for you to even define the format in which you require your error codes to be printed (i.e. base 2, base 8, base 10, base 16).

-ohno flag

So far we have a boilerplate that adds some context like the name, package, description, and code for an error. What if we require more context like source information, additional custom context, and a nested error? This is where the ohno package’s OhNoError comes in. This is a structure that also satisfies the error interface and also provides formatted output with wrapping and additional context addition.

type OhNoError struct {
 // An ohnoer.OhNoer interface error field
 ErrorCode error
 // A custom message for this instance of the error
 Message string
 // Any additional data to add context to this error
 Extra any
 // The error which led to this error being generated
 Cause error
 // File, Line & possibly Function name where this error was generated
 SourceInfo *sourceinfo.SourceInformation
 // Time at which this error occurred
 Timestamp time.Time
 // Layout in which the timestamp needs to be printed refer https://pkg.go.dev/time#pkg-constants
 TimestampLayout string
}

The -ohno flag for ohnogen will add the OhNo method to the boilerplate code so that you can directly generate OhNoError type errors from the enums.

func (i MyFabulousOhNoError) OhNo(message string, extra any, cause error, sourceInfoType sourceinfo.SourceInfoType, timestamp time.Time, timestampLayout string) (ohnoError error) {
 return ohno.New(i, message, extra, cause, sourceInfoType, sourceinfo.DefaultCallDepth+1, timestamp, timestampLayout)
}

Error Inspection and Wrapping

Inspection

For errors generated from ohnogen without the -ohno flag inspection can be done directly using a switch case or direct comparison.

switch err {
  case usage_without_ohno.AlreadyExists:
   prefix = "well its there already"
  case usage_without_ohno.NotFound:
   prefix = "nah I didn't find it"
  case usage_without_ohno.Busy:
   prefix = "not now!!"
  case usage_without_ohno.Fatal:
   prefix = "oh no its fatal!!!"
  default:
   prefix = "i don't know what this is"
}

For those generated with -ohno flag you can use the errors.Is method and inspect.

if errors.Is(err, usage_with_ohno.Fatal) {
   fmt.Printf("oh no its fatal!!!\n\t%s", err.Error())
 }

Wrapping & Unwrapping

When creating an error using the OhNo method you can embed an inner error by passing it as the cause argument. To unwrap the errors.Unwrap method can be used on the error.

Formatting

For formatting the OhNoError is directly compatible with JSON and YAML marshall and unmarshall methods.

And that's a wrap

I conclude this post by stating that error management and handling is something that is very important, however, the strategy and tactics involved in this vary across situations and personal preferences of a developer. This project was my take on how I would solve it in the situations that have been presented to me so far. There may be many cases where this will not be apt for usage as this is not a silver bullet. The links for this project are below and any comments/feedback is welcome.

Links




Continue Learning