Error Handling in GO

Error Handling & Abstraction in GO

GO uses its capability to return multiple values for error handling, meaning that errors in go are regular values returned from a function. The developer must check the returned error value and handle it accordingly.

Error handling can quickly become non-trivial. You may want to change your behaviour if an error occurs, either substitute the failed functionality to increase fault tolerance or display an easy-to-understand error message to the user that does not intimidate them with long traces.

If your software is large, you will want to introduce abstractions between packages to increase readability and reduce cognitive load on developers. Errors can be an easy way to break those abstractions.

On the other hand, you also don’t want to lose information from errors that may be useful for debugging or monitoring. There is no single solution, and depending on your requirements, you may want to adopt one of the solutions described here.

In this series, we will show you different approaches to tackling these issues ordered by the complexity of the requirements you may have.

Packages and Abstractions

We want to split our code into packages that provide an abstraction for different tasks in our operators. These packages should enable developers to focus on the task without worrying about the implementation details.

Thus, we want to provide a high-level API that does not expose implementation details. Naturally, these operations can fail, so we may have to return an error. These errors are usually particular to the implementation of the functionality.

Baseline: Opaque Errors

If all you need to communicate to the calling function is that an error has occurred, and the caller’s behavior does not change based on the content of the error, you can just return any error message directly. 

Depending on the context of your application, this error may be displayed to a User. If that is the case, make sure it is not cryptic and can be understood. Add useful details if you see fit. You can use fmt.Errorf to generate the error using a format string.

If that is not enough, perhaps you want to handle different errors differently?

The go proverbs state “Don’t just check errors, handle them gracefully”. To do that we need to know what went wrong, so we can handle it gracefully where possible. We do not want to parse the error messages strings. We’re looking for an easier way to differentiate between different errors that may have been returned.

Keep reading for tips on how to handle this and other complicated scenarios!

Example: Loading Configuration

The application may need to load configuration from a config file, a database, environment variables, or arguments from main.

We can define an Interface ConfigProvider that exposes a single method Get() that returns our configuration in a struct and an error if something goes wrong.

This allows us to pass a ConfigProvider to the Application, but the developer does not need to worry about where the actual configuration values are stored.

The errors that these implementations return would normally expose errors containing implementation details. If we cannot simply handle all errors, in the same way, we need to make distinctions between different error types. This means that implementation specifics are leaked to the calling program, which negates the advantages gained from abstracting away the implementation behind an interface.

We can create errors for each thing that can semantically go wrong and export them in the interface package. That way, all implementations can return these high-level errors without losing generality. Those errors are called Sentinels.

package configprovider

var (
  ErrInvalidConfig = errors.New(“invalid configuration”)
  ErrCannotLoad    = errors.New(“cannot load configuration”)
)

type ConfigProvider interface {
  Get() (*Config, error)
}

Approach 1: Directly Returning the Sentinel Errors From Implementations

We could choose to only return the sentinel errors exported in the interface from public API functions.

However, one of the downsides of this approach is that we lose context from the things that can go wrong in the implementation, e.g., missing configuration files, incorrect configuration values, the database being down, and insufficient permissions to access the configuration.

The original errors could be logged before the new, simplified error is returned. The downside is that it is harder to trace the origin of the error when debugging the code. Especially when the code is processing many requests concurrently, the log messages for both errors may not occur consecutively in the log, making the origin harder to trace.

Example from the stdlib: https://pkg.go.dev/io#pkg-variables

One example is the GO standard library. When a file is read, the EOF special value is returned when the reader has reached the end of the file. This is a behaviour we want to handle, of course. Usually, this means we can stop reading from the file. No further action is necessary.

The same package also has a couple of other Sentinel errors like ErrNoProgress, which indicates that reading did not get any new data. Another interesting case is ErrShortBuffer, which informs the caller that the buffer for the read operation is too small.

How Can I Start Applying This?

You can use this technique when writing a library with predefined error conditions like in the code snippet above.

Another use for this technique is when you write an application with multiple levels of abstraction. You handle an error in a package function on a different level of abstraction than the one calling it but still has some meaning to the caller; this is similar to the case of EOF in the standard library. The operating system gives us a magic value that indicates that the file has ended, and we return EOF to indicate that to the caller.

Weaknesses of This Approach

The main weakness of this approach is that static error messages can only express what went wrong but, in most cases, cannot explain how it went wrong. If the error is caused by invalid input from the user, we may also want to tell them which part of the input was invalid. 

To find out how we can include this information in our error message but still compare the what, stay tuned for Part 2 of this series, where we will go into error wrapping.

Leave a Reply

Your email address will not be published.

*