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.