Go Language Error Handling

From NovaOrdis Knowledge Base
Jump to navigation Jump to search

External

Internal

Overview

This article provides an overview of error handling in Go. It starts with error basics, where we explain that errors in Go are values, and functions return them as results. We continue by enumerating the ways error instances can be created and how the caller of a function that returns an error is supposed to handle it. We also discuss how to handle errors in concurrent programming.

Error Basics

A big part of programming is how you handle errors. Errors in Go are values of the error type, not control structures. Java and Python's error handling mechanisms rely on program flow control structures, in the form of exceptions. Go is different, in that is has no exceptions, which implies that any error condition that you will encounter in a program is by definition not exceptional, but part of the normal flow of the program. This approach makes the statement that error handling is important, and as we develop our programs, we should give our error paths the same attention we give our algorithms. The closest thing to exceptions in Go is the panic mechanism.

When an error condition occurs, which usually indicates that the system has entered in a state in which it cannot fulfill an operation that a user either or explicitly or implicitly requested, the function in question must return an error value in addition to the result via its support for multiple return values. The error is typically returned as the first and only argument, if the function does not return anything else, or the last argument otherwise:

func someFunc(...) error {
  ...
  err := ...
  return err
}
func someFunc(...) (result SomeType, [...], error) {
  ...
  err := ...
  return ...,...,err
}

Note that the function might return a concrete type of an error instead of the error interface, but this is usually a mistake. Returning the interface should be preferred.

If an error occurs, and the error value returned by the function is non-nil, all other values returned by the function must be ignored.

If no error occurs, the function must return nil as error value. In Go, nil means "no error".

When the error is created, it is the error implementation’s responsibility to summarize the context in the error message. The error string should start with a lowercase character, unless it begins with names that require capitalization. Error strings, unlike comments, should not end with punctuation marks. Error messages should be descriptive, yet compact. It should be easy to understand what exactly went wrong by reading the error message. Descriptive elements in the message help the operators of the program in figuring out what caused the error. For example, if a file cannot be opened because of lack of permission, the error message should be "open /some/path/myfile.txt: permission denied", not just "permission denied".

The caller of the function is supposed to handle the error. An error returned by a function can be ignored if the caller assigns it to the blank identifier _, but this approach is discouraged. Ignoring an error leads in most cases to bad outcomes. Handling an error means inspecting the error value and making a decision. Always handle the error. When handling the error, only handle it once.

The error message can be introspected by invoking Error() on the error instance. However, the result of this introspection should not be used in the program logic. That output is aimed at humans operating the program, not code. The contents of that string belong in a log file or displayed on the screen. You should not try to change the behavior of the program as result of processing the error message. If you need more context carried within the error instance, look into custom error types.

A valid pattern to handle an error is augmenting the error with more context and returning it to the upper layer. When possible, simply return the error unchanged. If you must add more context, prefer error wrapping over annotation.

Another error handling pattern is built around the fact that all errors belong to one of two categories: bugs and known edge cases (disk full, network failure, etc.). The pattern requires to represent the known edge cases as "well-formed" errors and declare them as part of our component API. All other errors are "raw errors" and represent bugs. When exported, errors become part of your package's public API and must be treated with as much care as you would any other part of your public API. This pattern is described here: Raw Errors and Well-Formed Errors.

Rob Pike's Go proverbs concerning errors: "Errors are just values" and "Don't just check errors, handle them gracefully". Also "error values in Go aren’t special, they are just values like any other, and so you have the entire language at your disposal".

Also see:

Go Style

Error Instance Creation

fmt.Errorf()

err := fmt.Errorf("some %s error", "blue")

The most common way to generate an error instance is fmt.Errorf(). fmt.Errorf() is equivalent to errors.New(), in that it produces a pointer to a new error instance, but it also allows formatting the error text using the capabilities of the fmt package. For this reason, fmt.Errorf() allows more flexibility and should be preferred over errors.New(). fmt.Errorf() invokes errors.New() internally. Each call to fmt.Errorf() returns a distinct error value even if the text is identical.

Error Wrapping

A new error instance can be created by wrapping an existing error with fmt.Errorf(). Error wrapping is an error handling idiom discussed in detail in Wrap the Error Instance below.

errors.New()

err := errors.New("some error")

The errors package New() function returns a pointer to a new error instance *errors.errorString that carries the given text. errorString is an unexported type of the errors package. Each call to New() returns a distinct error value even if the text is identical. The New() function does not have string formatting capabilities. The equivalent function fmt.Errorf() does.

Sentinel Errors

Sentinels are a special category of errors that are specifically intended to be identifiable using an equality test, which implies that only one error instance is used throughout the runtime. The name comes from the practice in computer programming of using a specific value to signify that no further processing is possible. In this case, we use specific values to signify an error, and we check for the presence of that value with the equality "==" operator or with errors.Is() (preferred). A sentinel error instance must have a unique identity, to make the evaluation with the equality test possible. An example of a sentinel error is io.EOF:

n, err := r.Read(buf)
if err == io.EOF {
 ...
}

The typical way to declare and use a sentinel error:

.
├── internal
│    ├── errs
│    │    ├── errs.go
... ...  ...

The package name is designed to avoid clashing with the built-in type error, with the errors package name and with the commonly used variable name err.

package errs

...

var NotYetImplemented = fmt.Errorf("not yet implemented")
var NotFound = fmt.Errorf("not found")

To check for occurrence, prefer errors.Is() over type assertions. errors.Is() is designed to work with both individual errors and wrapped errors.

func someFunc() error {
  return errs.NotYetImplemented
}

...

err := someFunc()
if errors.Is(err, errs.NotYetImplemented) {
  fmt.Printf("someFunc() is not yet implemented")
}

There are a few disadvantages to using sentinel errors.

The most serious is that the sentinel errors create a dependency between packages. For example, to check if an error is equal with io.EOF, you must import the io package. This particular example is not that bad, because io is quite common, but if this kind of coupling generalizes, it can become problematic. Using sentinel errors as part of the internal packages of a module and not exporting them as part of the public interface of the module is probably OK.

Another is that an attempt to annotate the sentinel error to provide more context would cause returning a different error would break the equality check. This is a limitation that can be worked around by wrapping the sentinel error inside another error and using errors.Is() or errors.As() to check for it.

In consequence, it improbably wise to avoid sentinel errors. There are a few cases when they are used in the standard library, but this is not a pattern you should emulate.

Custom Error Types

The error instances created with fmt.Error() and errors.New() carry context information in form of a detyped string. If we need to use context elements programmatically, during subsequent handling of the error, parsing the string is discouraged, as it can introduce brittleness. Custom error types is a better alternative, but it also comes with disadvantages, which will be discussed here:

Custom Error Types

Error Handling

Handling an error means inspecting the error value and making a decision. Always handle the error, never ignore it. When handling the error, only handle it once. Making more than one decision in response to a single error is bad. Logging an error is handling the error.

There are three choices when handling an error:

  1. ignore it by assigning to the blank identifier _ or "handling" it in an empty block - not recommended. However, if ignoring the error makes sense in a specific instance, also add a comment that explains why we are ignoring the error, to confirm to the code reader that ignoring the error is intentional and not a mistake.
  2. handle the error, deal with the consequences and continue the flow, without returning the error the calling layer
  3. return the error to the calling layer

Deal with the Consequences and Continue the Flow

When calling a function returning an error, always handle the error first.

r, err := someFunc()
if err != nil {
  // handle the error condition, ignore the rest of the results
  ...
}

decision. Always handle the error. When handling the error, only handle it once.


There's an if syntax that supports this error handling idiom:

if err := someFunc(); err != nil {
  // handle the error condition
  return
}
// handle success

For more details and advice on use see:

if syntax where the statement precedes expression

Return the Error to the Calling Layer

The error can be returned to the calling layer unmodified, it can be annotated while being morphed into a different error, or it can be wrapped into a new error while preserving its identity. Different situations call for different approaches, as discussed below.

Handle the Error as Opaque and Return It Unmodified

func someFunc() error {
  ...
  err := someOtherFunc()
  if err != nil {
    return err
  }
  ...
}

Handling the error as opaque and just returning it as function result is the most flexible error handling strategy because it requires the least coupling between your code and the caller. It is opaque because while you know that an error occurred, you don't introspect the error. This approach is appropriate when there is no additional context to be added to the error, and also we do not want to mark the error as a more specific type. The problem with this approach is that the information where this error comes from is lost. Lost how? Update this section after researching stack traces.

Annotate the Error

The error instance returned from the underlying layer can be "annotated" with a string according to the following pattern:

err := someFunc()
if err != nil {
   return fmt.Errorf("some processing failed: %v", err)
}

The key is using fmt.Errorf() and the %v conversion character. The annotation process transforms the original error by creating a new error instance whose message is modified to add extra context, in this case "some processing failed". Note that the original error instance is discarded, along with its unique identity, and not embedded in the new error value. This pattern is incompatible with sentinel error values and errors.Is() and type assertions because it converts the original error instance to a string, it merges it with another string, and then it converts the string back to a new error. This breaks the type information that might be otherwise passed. Error wrapping should be preferred over error annotation when it comes to provide additional context around an error.

This approach is appropriate when we need to add more context to an error returned by the lower layer, while we do not care about preserving the error identity.

Wrap the Error Instance

Go error mechanism allows "wrapping" error instances into other error instances, while preserving the wrapped error identity. This pattern supports building an error tree that is useful in providing deep context to an error, and it should be the preferred pattern to use when we want to provide additional context to an error.

There are two situation when wrapping is an appropriate approach: when providing more context to an error while preserving the error history, and also when we want to mark an error as a specific type. Error wrapping is discussed in detail here:

Go Error Wrapping

Only Handle an Error Once

An error must be handled once. This is an example of handling an error twice:

result, err := someFunction()
if err != nil {
  // handling it once: we annotate the error and then send it to the log file
  log.Println("something went wrong: ", err)
  // handling it the second time: we send the unannotated error to the caller
  return err
}

We log the annotated error and return the unannotated error to the caller, who possibly will log it, and return it, all the way back up to the top of the program. In the end, we get a stack of duplicate lines in the log file, and at the top of the program we get the original error without any context. Having more than one log line for a single error is a problem, because it makes debugging harder.

Logging an error is handling the error. Logging the error and returning the same error is handling the error twice. We should log or return, never both.

Concurrent Programming Error Handling

This section discusses error handling in the context of concurrent programming with goroutines and channels.

In case of concurrent processing, the concurrent functions should send their errors to another part of the program that has complete information about the state of the program, and that can make more informed decision on what to do. A common pattern to implement this:

  1. Introduces a type that encapsulates the valid result and the error.
  2. Return the instances of that type on the channel that is supposed to carry the valid result.
  3. The reader on the channel, which is supposed to know what to do with the result, it is in a better position to decide what to do in case of error, in a richer context.

The instances of the <valid-result, error> type represent the complete set of possible outcomes of the concurrent code that produces the results, and allows us to separate the concern of handing the errors from that of producing the results. The goroutine that spawned the producer goroutine has a more complete context and can make more intelligent decisions about what to do with the errors.

The error Type and error Values

https://pkg.go.dev/builtin#error

The error is a pre-declared interface type, part of the universe block, and constitutes the conventional interface for representing an error condition, with the nil value representing no error.

type error interface {
  Error() string
}

The Error() method returns the string rendering of the error message. Errors in Go are instances that implement the error interface. When printing the error using methods like fmt.Println() the Error() method is automatically invoked.

Values of any type that implements the error interface can be used as error values and will be handled as error values by the Go runtime. error values are required to be more than just an error code, or a mere success/failure indicator.

The errors Package

https://pkg.go.dev/errors

The errors package contains functions for manipulating errors: creating new instances with errors.New(), checking for wrapped errors with errors.Is(), errors.As(), etc.

Stack Traces

A stack trace in form of a []byte is generated with:

import "runtime/debug"
...
ba := debug.Stack()

To print the stack trace at stderr:

import "runtime/debug"
debug.PrintStack()

Update above.

Errors and Logging

err := fmt.Errorf("this is an error")
log.Printf("%v\n", err)

prints:

2023/12/30 19:02:06 this is an error

How do I display stack traces?

panic

panic

NotYetImplemented error

For a typical implementation and usage, see Sentinel Errors.

Error Patterns

Raw Errors and Well-Formed Errors

Raw Errors and Well-Formed Errors