Tuesday, January 12, 2021

Go (golang) Error Handling - A Different Philosophy

In looking at Go (golang) as someone who has spent quite a bit of time in C#, I'm really intrigued by the approach to error handling.
Go has "error" to represent problems that can potentially be handled and "panic" for problems that force an application to exit.
This is significantly different from exceptions and the exception handling mechanism that we see in C#.

Motivation: I have been using C# as a primary language for 15 years. Exploring other languages gives us insight into different ways of handling programming tasks. Even though Go (golang) is not my production language, I've found several features and conventions to be interesting. By looking at different paradigms, we can grow as programmers and become more effective in our primary language.

Resources: For links to a video walkthrough, CodeTour, and additional articles, see A Tour of Go for the C# Developer.

Exceptions in C#

In C#, we are used to exceptions for error handling. An exception is a complex error object that contains a specific type, message, (potentially) an inner exception, a call stack, and other things.

In addition, we rely on an exception handling system that takes care of things for us. When an exception is thrown, it walks up the call stack looking for a "catch" that handles the exception. If it doesn't find one (i.e., the exception is unhandled), the application exits -- usually ungracefully.

As programmers, we can try to catch an exception using a try / catch block. If we think we can do something with the exception, we handle it. Otherwise, we let is go up the call stack to see if someone else can handle it.

We're used to letting the infrastructure do much of the work for us (not that there's anything wrong with that).

Go takes a different approach.

"error" in Go

In contrast, Go uses an "error" (an interface type) that is, at its heart, a wrapper around a string. "error" is a common return type for a function. And this really leaves it up to the programmer to deal with errors a bit more directly. There is no infrastructure for us to tap in to.

Example
Here's a sample function. We'll break down bits of it to see the conventions for error handling in Go. You can see this function on GitHub in the "CodeTour: Go for the C# Developer" repository: https://github.com/jeremybytes/go-for-csharp-dev/blob/main/async/main.go (starting on line 13).


Conventional Return Values
Let's look at line 14 for a typical way that errors are provided.

Go
    resp, err := http.Get("http://localhost:9874/people/ids")

The "Get" function returns 2 values (multiple return values are supported in Go). In this case, "Get" returns a pointer to an HTTP response (*Response) and an error (error). These are captured in the variables "resp" and "err", respectively.
Convention: A function returns both data and an error. A non-nil error value means that an error occurred.
This is a common pattern: to return data and an error. If the function needs to return more than one value, then the error is the last return value in the list. This is all by convention; there is nothing in the compiler that enforces this (although a good linting tool will help you out quite a bit -- I use the Go extension with Visual Studio Code).

Checking for Errors
After calling a function that returns an error, the next step is to see if the error is populated. Here is line 14 repeated along with the following lines:

Go
    resp, err := http.Get("http://localhost:9874/people/ids")
    if err != nil {
        return nil, fmt.Errorf("error fetching ids: %v", err)
    }

This checks to see if the error is not nil (null in C#). If it is not nil, then the function short-circuits by returning -- in this case using "nil" for the data along with a populated error (we'll look at how the error is created in just a bit).

There are a couple of conventions to note here. First, we check that "error is not nil" before using the data. Next, the "if" does not have a corresponding "else"; instead, the "if" generally exits out of the function (often by returning its own error).
Convention: Check the error before using any data returned from a function. 
Convention: If there is an error, short-circuit the rest of the function.
Note: these are not the only options. For web calls, we can always retry on error, and there are ways to dig deeper to see if we can figure out the source of the problem. But typically, we pass the error along.

Formatting Errors

In the sample above, we use the "fmt.Errorf" function to create an error by wrapping another error. Let's look at this a bit more closely.

Go
    if err != nil {
        return nil, fmt.Errorf("error fetching ids: %v", err)
    }

As mentioned above, an "error" is more or less a wrapper around a string. The "Errorf()" function returns an "error". The parameter is a string that contains whatever information we want to pass along.

Since we already have an error, we want to append information specific to our function and then pass along the error we received. "Errorf" helps us do that.

The parameter for "Errorf" uses placeholders (known as "verbs" in Go) that are similar to the placeholders in "string.Format()" in C#. The "%v" verb will be filled in with "err". ("%v" means that "natural format" in Go -- this is similar to "ToString" in C#.)

Prepending Errors
By convention, we prepend the incoming error with our message. In the above example, we use "error fetching ids: [incoming error]". This is all by convention.
Convention: Specific error messages are prepended to an existing error.
In addition, due to the conventions around error messages, capitalization and punctuation are important. Because we are creating a string of error messages, we should not include capitalization (implying a "new" thing). For the same reason, we should not include line breaks or other terminators (such as a period).
Convention: Error messages should start with a lower-case letter.
Convention: Error messages should not have line breaks or periods.
These conventions will make more sense when we look at some output.

Sample Error Message

Let's continue with our example to show where the "getIDs" function is called -- this is on line 68 of the previously linked file on GitHub: https://github.com/jeremybytes/go-for-csharp-dev/blob/main/async/main.go:

Go
    ids, err := getIDs()
    if err != nil {
      log.Fatalf("getIDs failed: %v", err)
    }

This calls the "getIDs" function and then checks for an error. If there is an error, we use the "log.Fatalf" function to log the error and exit the application.

log.Fatalf
"log.Fatalf" does 2 things. First, it prints a formatted string to the log (which goes to the console by default). This string includes a timestamp. Next, it exits the application (using "os.Exit(1)" to denote that the application exited with an error). Notice that just like with our "Errorf" call above, we prepend our own message before including the error.

As an reminder from a previous article, when we use "os.Exit()" or "log.Fatalf", deferred items do not run. See "Go (golang) defer - A Better finally" for more information on "defer".

Error Message
The function call to "getIDs" tries to get a slice of integers from a web service. If the web service is not available (for example, if the service is not running), then we get the errors that we have seen here.

Here is the resulting message:
2021/01/10 15:47:24 getIDs failed: error fetching ids: Get "http://localhost:9874/people/ids": dial tcp [::1]:9874: connectex: No connection could be made because the target machine actively refused it.

If we break this message down, we can see the how the conventions come together.

2021/01/10 15:47:24 getIDs failed:

This part comes from the "log.Fatalf" function from the "main" function. We can see the timestamp along with our message "getIDs failed".

error fetching ids:

Next we see the message from the result of the "fmt.Errorf" function call in the "getIDs" function -- our custom message "error fetching ids".

Get "http://localhost:9874/people/ids": dial tcp [::1]:9874: connectex: No connection could be made because the target machine actively refused it.

The rest of the message is the error that comes from the "http.Get" function call. Based on the conventions, I would assume that the first message ("Get" with the URI) comes from the "http.Get" function, and the rest comes from the lower level functions that are called within "Get".

A Sort of Call Stack
What we see when we put this all together (using the conventions noted above) is a sort of call stack. The colons are delimiters as we walk down the stack to the original error message.

This is definitely not as formal as the call stack that is part of a C# exception, but it is still useful for tracking the ultimate source of error messages.

Error Handling Philosophy

In Go, handling errors is entirely up to the programmer. We need to check the errors that are returned from functions. If an error is populated, then we decide what to do with it. Even if we are not prepared to handle it directly, we should pass it along as a return value.

This is quite a bit different from C#. If a function throws an exception in C#, we can decide whether we want to handle it or not. If we leave it unhandled, then it walks up the call stack until it finds a handler. And whether we decide to "catch" it or not, the normal execution stops.

This means that in Go we need to be more aware of what functions return errors and how we want to deal with them. But it is entirely up to us to decide.

Ignoring Errors
One option is to ignore errors. There is nothing that forces us to capture an error value that comes back from a function. Here's an example:

Go
    resp, _ := http.Get("http://localhost:9874/people/ids")

This uses the same "http.Get" call from above. But instead of putting the error value into an "err" variable, we use a "blank identifier". This is a discard. The value of the error is not assigned to anything, and we have no visibility to it. The error is completely ignored.

This may work if we have a simple environment that we have full control over, but this is not very common. Instead, what is likely is that we will get a "panic".

"panic"

A "panic" in Go is most similar to an unhandled exception in C#. A "panic" occurs when an illegal operation is performed -- such as reading beyond the end of an array.

When an "panic" happens, the application will exit. There is no way to "handle" a panic. The good news is that any deferred operations will still run. See "Go (golang) defer - A Better finally" for more information on "defer".

For the sample application, if we do ignore the error from the "http.Get" function, and the service is not running, we get a "panic". Here is what gets dumped to the console:

panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xc0000005 code=0x0 addr=0x40 pc=0x64de30]

goroutine 1 [running]:
main.getIDs(0xbff756a8f5b21990, 0x2d864d, 0x8f99e0, 0x0, 0x0)
    C:/GoCodeTour/async/main.go:18 +0xa0
main.main()
    C:/GoCodeTour/async/main.go:60 +0x63

This shows the runtime error: "invalid memory address or nil pointer dereference". I would guess that the problem is a "nil pointer dereference". Above, we saw that "http.Get" returns a pointer to an HTTP response (*Response). This value will be nil due to the error.

So even though we can ignore errors, it is not a good idea.

Conventions

Let's do a quick review of the conventions:
  • A function returns both data and an error. A non-nil error value means that an error occurred.
  • Check the error before using any data returned from a function.
  • If there is an error, short-circuit the rest of the function.
  • Specific error messages are prepended to an existing error.
  • Error messages should start with a lower-case letter.
  • Error messages should not have line breaks or periods.
None of these conventions are required by the compiler, but we will have a much easier time if we follow them.

What I Find Interesting

I think this philosophy for error handling is quite interesting. There is a clear division between "things I can potentially deal with" (error) and "things I cannot deal with" (panic).

As much as I appreciate the exception handling mechanism that is built in to C# (and the ways that I have taken advantage of that in my own programming), I like the approach of returning an error value from a function. This feels like the programmer has a bit more control over the situation.

It can also be quite a bit more dangerous since it is possible to ignore errors and continue -- that's quite a bit harder with exceptions.

I do like the idea of a "panic". This means that I am in truly an invalid state (trying to dereference a nil pointer, trying to read beyond the end of an array). The "panic" ensures that the application exits, hopefully before any damage can be done.

This gives me a clear delineation between errors I can potentially deal with and those that I cannot.

Overall, there are things to be said for both approaches. But by looking at a different paradigm, it gives me the opportunity to rethink how I program. Are there situations where I can use a more Go-like error handling mechanism? Maybe.

Ultimately, learning how other environments work makes our world a bit bigger and gives us other options to consider.

Happy Coding!

No comments:

Post a Comment