jiechen257

jiechen257

twitter
github

Go Error Handling Elegance

Current Go version 1.17.5

Error Handling Design in Go#

There has always been controversy surrounding error handling in Go. In fact, Go itself has faced its fair share of controversies 😂. Without passing judgment, let's compare Go with mainstream languages and summarize its characteristics.

As a language with a long history, error handling in C is quite messy. Typically, C uses return values to indicate whether an operation was successful, and the specific reason for failure is passed through an additional global variable. The fatal flaw of this design is that errors are mixed with return results. For example, in the function int atoi(const char *str), what should be returned if the conversion fails? 0? -1? In reality, neither of these options is reasonable because any number could represent a valid result.

As a result, some developers choose to use pointer passing for results, with the return value indicating only whether an error occurred. This does solve the aforementioned problem, but it also leads to confusion in terms of "parameters" semantics. Inexperienced developers are left puzzled by input and output parameters.

On the other hand, modern languages often adopt the try-catch approach, such as Java and Python. This model separates errors from return values and parameters, while also providing the possibility for structured handling. By using object-oriented thinking, developers can define custom error classes and subclasses, which can wrap other errors to ensure that error context is not lost.


Go, however, is different. It utilizes the feature of multiple return values to separate errors. Therefore, in many Go functions, the last return value is an error that indicates whether an error occurred. Compared to C, this is a step forward, but it does not provide a flexible error handling mechanism, which is a matter of personal preference.

In my opinion, we should not attempt to replicate the functionality of language B in language A. Instead, we should implement targeted error handling solutions based on the characteristics of the language itself, guided by macro concepts. The following sections will explore this viewpoint further.

Fundamental Principles#

Although the syntax may differ, certain language-agnostic concepts are still important and should be followed as much as possible.

Firstly, an error message should serve at least two purposes:

  1. To be understood by the program, allowing it to enter the appropriate error handling branch based on the error type.
  2. To be understood by humans, providing information about what exactly went wrong.

Avoid Duplicate Error Handling#

In my opinion, this is a common pitfall. Consider the following code:

func foo() (Result, error) {
 result, err := repository.Find(id)
 if err != nil {
   log.Errof(err)
   return Result{}, err
 }
  return result, nil
}

Here, the error is logged (i.e., handled) and then returned. It is easy to imagine that the caller of foo() will log the error again and return it. In the end, a single error will result in a large number of log entries.

Include Call Stack in Errors#

The call stack is essential for debugging. If errors are simply returned layer by layer, the top-level function will receive the error from the bottom-most layer. Imagine writing a web backend in Go and encountering an I/O error while processing a payment request. This would be of no help for debugging, as it would be impossible to determine which part of the process caused the error.

Another bad approach is for each layer to only return its own error, for example:

func pay(){
  if err := checkOrder(); err!=nil {
    return errors.New("Payment exception")
  }
  return nil
}

func checkOrder() error {
  if err := calcMoney(); err!=nil {
    return errors.New("Calculation exception")
  }
  return nil
}

func calcMoney() error {
  if err := querySql(); err!=nil {
    return errors.New("Database query exception")
  }
  return nil
}

In this case, the top-level function can only obtain the error from the nearest layer and has no knowledge of the underlying cause. Clearly, this is not what we want.

Errors Should Be Structured#

Some clever individuals have modified the code to address the issue of lost call stacks:

func checkOrder() error {
  if err := calcMoney(); err!=nil {
    return fmt.Errorf("Calculation exception: %s", err)
  }
  return nil
}

Indeed, after making this modification, the top-level function receives the following exception:

Payment exception: Calculation exception: Database query exception

Now, the call stack is visible to humans, but only to humans. Is this error related to a database exception? The computer has no way of knowing. We could solve this through natural language processing, unless you've gone crazy.

Therefore, structured errors are needed—errors that have a hierarchical relationship, where child errors are a type of parent error. This allows top-level functions to easily determine the type of exception.

Errors Should Include Context#

This point is self-explanatory. We want error logs to include relevant data, such as user IDs, order IDs, etc.

It is worth noting that this principle should be combined with "avoid duplicate error handling." Otherwise, we would end up with logs like this:

Payment exception uid=123, orderId=456, reqId=328952104: Calculation exception uid=123, orderId=456, reqId=328952104: Database query exception uid=123, orderId=456, reqId=328952104

Practice#

Error Chains#

In the past, preserving call stacks and structured errors in Go was quite cumbersome, which is why the open-source library errors was widely used. However, Go 1.13 has somewhat improved error handling, and Go 2 plans to further enhance it. As a result, this library is now in maintenance mode.

Now, we no longer need to construct call stacks ourselves. We can simply wrap an error using fmt.Errorf("... %w", ..., err) to create an error chain. Similarly, we can use errors.Is() or errors.As() to determine if an error (chain) is the same as (or contains) another error. The former requires strict equality, while the latter only requires the same type. Unbeknownst to us, "structured" error handling has also been largely achieved.

Wait a minute! Why did I say "largely"? Although the standard library provides methods for checking error types, what exactly is an error type? In comparison to Java, which throws specific exceptions, Go generally only returns the underlying error interface error without a specific structure. So how do we determine the error type? Are we going back to the days of err.msg? Of course not. Besides using fmt, we can also define our own structure when wrapping an error. In reality, fmt.Errorf() returns a wrapError, which is essentially a convenience function used in scenarios where the specific error type is not needed.

Custom Structs#

Custom error structs not only help identify error types but also solve the context problem. Using a simple string is acceptable, but a better approach is to include the context as a field in the struct:

type orderError struct {
  orderId int
  msg string
  err error
}

func (e *orderError) Error() string {
    return e.msg
}

func (e *orderError) Unwrap() error {
    return e.err
}

This struct not only implements the error interface but also has an additional Unwrap() method, allowing it to wrap other exceptions and ensure that the call stack is not lost.

Now, we can return the error like this:

func checkOrder() error {
  if err := calcMoney(); err!=nil {
    return return orderError{123, "Calculation exception", err}
  }
  return nil
}

Public or Private#

Once we have our own struct, the question arises: should it be public? The standard library has already provided an answer to this question: in general, it should not be public. Therefore, most functions we encounter will only return error instead of xxxError. According to online sources, this approach aims to hide implementation details, improve library flexibility, and reduce compatibility issues during upgrades.

By not making the struct public, it means that it cannot be determined using errors.As(). To address this, we can create a public function to help external code determine if the error belongs to our library:

func IsOrderError(err error) bool {
  return errors.As(err, orderError)
}

Error Check Hell#

The previous sections summarized the structure for returning errors, which aligns with the fundamental principles outlined at the beginning. In actual code, error checks can become pervasive throughout a project, with every function call wrapped in an if statement to promptly interrupt and return—since Go does not have a throw or raise mechanism. Take a look at this nasty example:

func checkPerson(*p Person) error {
  if err := checkAttr(p.name); err != nil{
    return err
  }
  if err := checkAttr(p.age); err != nil{
    return err
  }
  if err := checkAttr(p.country); err != nil{
    return err
  }
  if err := checkAttr(p.work); err != nil{
    return err
  }
  return nil
}

Extract Anonymous Functions#

This method is suitable for consecutive calls to the same function.

Extract the error check into an anonymous function. If an error already exists, the function will not execute and will simply return.

func checkPerson(*p Person) error {
  var err error
  check := func(attr interface{}){
    if err != nil{
      return
    }
    err = checkAttr(attr)
  }

  check(p.name)
  check(p.age)
  check(p.country)
  check(p.work)
  // more checks
  return err
}

Utilize Panic#

⚠️ Using panic as a substitute for error handling is a bad practice. Do not abuse this technique.

// checkAttr() no longer returns an error, but panics directly
func checkAttr(attr interface{}) {
  if attr == nil{
    panic(checkErr{...})
  }
}

func checkPerson(*p Person) (err error) {
  defer func() {
    if r := recover(); r != nil {
      // Recover the panic from checkAttr() and convert it to an error
      err = r.(checkErr)
    }
  }()

  // do anything
}

Using panic to simplify error checks is key, but it is important to only handle known errors during recovery. For unknown situations, the panic should continue to propagate. In principle, panic should only be used for unrecoverable and severe errors (such as array out of bounds), and if not handled properly, it could lead to unknown consequences.

Some websites suggest the following approach, which is highly discouraged unless you know what you're doing:

func checkPerson(*p Person) (err error) {
  defer func() {
    if r := recover(); r != nil {
      var ok bool
      // This also captures unknown errors
      err, ok = r.(error)
      if !ok {
        err = fmt.Errorf("failed to check person: %v", r)
      }
    }
  }()
  // do something
}

Although many websites advocate against abusing panic, I believe that if panic is used as shown in the first example, where we ensure that only known exceptions are caught to simplify error checks, it should not be considered abuse. In this case, panic will not have any impact on callers outside the package—functions that previously panicked will continue to panic, and functions that previously returned an error will still only return an error. The official Go documentation effective go also acknowledges this usage:

With our recovery pattern in place, the do function (and anything it calls) can get out of any bad situation cleanly by calling panic. We can use that idea to simplify error handling in complex software.

With the recovery pattern in place, we can easily handle exceptions by calling panic whenever necessary. This approach can be used to simplify error handling in complex software.

Reprinted From#

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.