20 Error Handling Part 2

20 Error Handling - Part 2 #

Hello, I’m Haolin. Today, we will continue sharing about error handling.

In the previous article, we mainly discussed “how to handle error values” from the perspective of the user. Now, we need to focus on the perspective of the builder and consider “how to provide users with appropriate error values.”

Knowledge Expansion #

Question: How to provide appropriate error values based on actual situations?

We already know that there are two basic ways to build an error value system: creating a hierarchical system of error types and creating a flat list of error values.

Let’s start with the hierarchical system of error types. Since implementing interfaces in Go is non-intrusive, we have a lot of flexibility. For example, in the net package of the standard library, there is an interface type called Error. It can be seen as an extension of the built-in interface type error because net.Error is an embedded interface of error.

In addition to having the Error method of the error interface, the net.Error interface also declares two additional methods: Timeout and Temporary.

In the net package, many error types implement the net.Error interface, such as:

  1. *net.OpError;
  2. *net.AddrError;
  3. net.UnknownNetworkError, and so on.

You can imagine these error types as a tree, with the built-in interface error as the root of the tree and the net.Error interface as the first-level non-leaf node extending from the root.

At the same time, you can also see this as a multi-level classification method. When a user of the net package receives an error value, they can first determine whether it is of the net.Error type, in other words, whether the value represents a network-related error.

If it is, then we can further determine which specific error type it belongs to, so that we can know if the network-related error is caused by improper operation, address issues, or incorrect network protocols.

When we examine the implementation of these specific error types in the net package, we will also find that, similar to some error types in the os package, they also have a field named Err with the type of the error interface, representing the underlying error of the current error.

Therefore, there can be another relationship between these error types: a chain relationship. For example, when a user calls a function like net.DialTCP, the code in the net package might return a *net.OpError error value to indicate an error caused by the user’s improper operation.

At the same time, this code might assign a *net.AddrError or net.UnknownNetworkError value to the Err field of the error value to indicate the underlying cause of this error. If the Err field of this underlying error value also has a non-nil value, it will indicate a deeper level of error cause. In this way, one level after another, like a chain, ultimately points to the root cause of the problem.

To summarize all of the above in one sentence, use types to establish a hierarchical error system and use a unified field to establish a traceable chain of error associations. This is an excellent example provided by the Go standard library, which is very instructive.

However, note that if you don’t want your returned error values to be modified by code outside the package, you must make sure the initial letters of the field names are lowercase. You can expose certain methods to allow external code to have the permission to further access error information, such as writing a public method Err that can return the value of a package-level private err field.

Compared to the hierarchical system of error types, a flat list of error values is much simpler. When we just want to create some error values representing known errors in advance, this flat approach is appropriate.

However, since error is an interface type, error values generated through the errors.New function can only be assigned to variables and not to constants. And because these variables representing errors need to be used by code outside the package, their access permissions can only be public.

This brings up a problem. If malicious code changes the values of these public variables, the functionality of the program will inevitably be affected. Because in such cases, we often use equality checks to determine which specific error we obtained. If the values of these public variables are changed, the results of the corresponding equality checks will also change.

Here are two solutions. The first solution is to make these variables private, meaning to change the initial letters of their names to lowercase, and then write public functions to get the error values as well as to perform equality checks on error values. For example, for the error value os.ErrClosed, first rename it to os.errClosed, and then write the ErrClosed function and IsErrClosed function.

Of course, this is not to say that you should modify the existing code in the standard library. The harm of doing so would be significant, even fatal. I can only say that for the code under your control, it is best to tighten the access permissions as much as possible.

Let’s talk about the second solution, which exists in the syscall package. In this package, there is a type called Errno, which represents low-level errors that may occur during system calls. This error type is an implementation of the error interface and is also a redefinition of the built-in type uintptr.

Since uintptr can be used as the type of a constant, naturally syscall.Errno can be used as well. The syscall package declares many constants of the Errno type, and each constant corresponds to a system call error. Code outside the syscall package can obtain these constants that represent errors, but cannot change them.

We can follow this declaration style to build our own error value list, which ensures the read-only nature of error values.

Well, in summary, although a flat error value list is relatively simple, you must know the hidden dangers and effective solutions.

Summary

Today, I summarized the techniques and design methods for handling error types and error values from two perspectives. We first took a look at the most basic way to handle errors in Go, which involves function result list design, the errors.New function, guard statements, and using print functions to output error values.

Next, the first question I raised was about error checking. For an error value, we can obtain its type, value, and the error message it carries.

If we can determine its type range or value range, we can use certain methods to know the specific error category. Otherwise, we can only roughly differentiate them by matching the error messages they carry.

Since the error messages given by the underlying system are still structured and regular, this method of judgment is quite effective. However, the error messages given by third-party applications may not be so regular, making it difficult to identify the categories based on error messages.

With the above explanations, when we change our perspective from a user to a builder, we often consciously think about the design of the program’s error system. Here, I proposed two widely used solutions in the Go standard library, which are: a three-dimensional error type system and a flat error value list.

The reason why the error type system is three-dimensional is because it often presents a tree-like structure as a whole. By nesting interfaces and implementing interfaces, we can build a tree of error types.

Through this tree, users can determine the type of error value step by step. In addition, for the needs of tracing back to the source, we can also place a field in the error type that represents potential errors. This is called chained error correlations, which can help users find the root cause of errors.

In comparison, the error value list is relatively simple. It is actually a collection of error values with different names but the same type.

However, it should be noted that if they are public, it is best to make them constants rather than variables, or write private error values and public functions to retrieve and compare them. Otherwise, it is difficult to avoid malicious tampering.

This is actually a specific embodiment of the program design principle of “minimizing access permissions”. No matter how we design the program’s error system, we should consider this point.

Discussion Questions

Please list three error values that you frequently use or see, and specify which error value list they belong to. Which category of errors are included in these error value lists?

Click here to view the detailed code accompanying the Go Language column article.