Adding Context To Go Errors

Posted on
go golang error handling

When I first started writing Go I treated errors without the respect I gave exceptions in other languages like C#. I attribute this behavior to my misinterpretation of the phrase errors are values.

I want to share a few habits with regards to writing error messages in Go that I think are useful for developers to use.

The following suggestions are by no means the correct to write error messages. I’m not claiming that this method is better than any other method, only that it worked for me and you may find it helpful.

Errors And Omissions

It’s true that errors are values. It’s not true that errors are JUST values and you do not need to think about them. Let me illustrate with an log message that reads like so:

2018/05/15 19:00:10 error updating person record with id p-123456

We can extract the following two pieces of information from this message:

  • There was an error updating person record with id = p-123456
  • The error occurred on sometime around 2018/05/15 at 19:00:10

What’s not included in this message is the context which has been omitted from the message:

  • Execution path (what if the error could be generated from multiple different code execution paths)
  • Input transformations (did the input id undergo any transformations before being logged?)

Execution Paths

There are two ways to identify the path code has travelled to reach an error message. A user can either read a stack trace or read an error message which embeds the execution in the error message. The latter will be explored in the section Adding Context to Errors.

Stack traces are an excellent source for tracing execution paths, however, Go errors do not expose stack traces. To work around this, developers often reach for a library like Dave Cheney’s pkg/errors library. I think more often than not, well crafted error messages can replace stack traces.

The more Go I’ve written, the less I’ve relied on third party error packages. I think it’s incorrect to lean on a third party dependency to make up for weak error messages.

The responsibility of providing meaningful error messages lies with the developer who creates or handles an error.

Input Transformations

As far as I’m aware, input transformations can only be traced if they are added to your error messages. A technique is explored in the next section, Adding Context to Errors.

Adding Context To Errors

If you’ve used Go for any period of time then you’re familiar with snippets like:

	_, err := someOperationThatMayErr()
	if err != nil {
		// ??
	}

The question you’re faced with in this situation is: “How do I handle this error?”

The answer depends on the intent of the code which invokes this snippet.

For the sake of example, assume the code in the next section considers an error handled by adding additional information to the error message and return it to the caller.

Example: Passing An Error Back With Context

Let’s imagine we have a package called foo which exports a function DoThing and a type Bar, defined below.

package foo

// imports omitted

func DoThing(inputValue int) error {
	err := validateInput(inputValue)
	if err != nil {
		// do something, return an error
	}
	
	err = baz.Operation(inputValue)
	if err != nil {
		// do something, return an error
	}
}

func validateInput(inputValue int) error {
	if inputValue <= 0 {
		// return an error
	}
	
	if inputValue >= 100 {
		// return an error
	}
	
	return nil
}

type Bar struct {}

func (Bar) DoBaz(inputValue int) error {
	err := validateInput(inputValue)
	if err != nil {
		// do something, return an error
	}
	
	err = baz.Operation(inputValue)
	if err != nil {
		// do something, return an error
	}
}

DoThing might return an error due to invalid input (verified by validateInput) or because baz.Operation returned an error.

Bar’s DoBaz might return an error due ot invalid input (validated by validateInput) or because baz.Operation returned an error.

The way I would add additional context to these errors is through use of fmt.Errorf, as well as a well defined message scheme.

package foo

// imports omitted

func DoThing(inputValue int) error {
	err := validateInput(inputValue)
	if err != nil {
		return fmt.Errorf("foo.DoThing %d: %v", inputValue, err)
	}
	
	err = baz.Operation(inputValue)
	if err != nil {
		return fmt.Errorf("foo.DoThing %d: %v", inputValue, err)
	}
}

func validateInput(inputValue int) error {
	if inputValue <= 0 {
		return errors.New("input value must be greater than zero")
	}
	
	if inputValue >= 100 {
		return errors.New("input value must be less than 100")
	}
	
	return nil
}

type Bar struct {}

func (Bar) DoBaz(inputValue int) error {
	err := validateInput(inputValue)
	if err != nil {
		return fmt.Errorf("foo.Bar.DoBaz %d: %v", inputValue, err)
	}
	
	err = baz.Operation(inputValue)
	if err != nil {
		return fmt.Errorf("foo.Bar.DoBaz %d: %v", inputValue, err)
	}
}

Error Message Formatting Explained

Note in the example code, the scheme I use for formatting error messages on package level operations is defined as:

[package.Function] [inputs]: [message]

For formatting error messages generated by methods on a type I use the following:

[package.Type.Function] [inputs]: [message]

Note that I do not wrap context around errors which are generated in the non-exported validateInput.

I avoid wrapping context around non-exported functions because they may be used from multiple different places in a package. I leave the responsibility of adding context to the exported types.

With the previous message formatting scheme in place, you’ll be able to generate error messages that resemble the following:

services.PersonService.SetName 123456 "John Doe": db.Persons.Update p-123456 "John Doe": error updating person record with id p-123456

This can scheme can be extended as far as necessary. It does not offer the file and line number at which an error occurred, instead, it offers the context under which the error occurred.

This error messages now contain the path the code took as well as the inputs to each function.

Conclusion

Error messages without context may be more difficult to act upon with a lack of context.

Using well crafted error messages, an error can reveal the path the code travelled as well as the conditions under which the error occurred.

You can use fmt.Errorf to wrap your error messages with additional context, including the inputs for the invoked operations.

Consider using the following format schemes for your error messages:

[package.Operation] [inputs]: [message]
[package.Type.Operation] [inputs]: [message]

If you need stack traces with your error messages, consider using Dave Cheney’s pkg/errors library.

Thanks for reading