In this post we will discuss Go error handling alternatives. Go provides several methods to handle errors. Let's examine these methods.
TL;DR
The Panic Method
The basic method to handle errors is using the panic function, which is similar to throwing an exception.
Once panic is called, it creates an error which stops the current go routine execution raised until the top level that handles it. By default, unless something was add to recover from the error, the program terminates.
package main
import "os"
func main() {
f1()
}
func f1() {
_, err := os.ReadFile("myfile.txt")
if err != nil {
panic(err)
}
}
And the output is:
panic: open myfile.txt: no such file or directory goroutine 1 [running]: main.f1(...) my-program/src/main.go:12 main.main() my-program/src/main.go:6 +0x45
But, this kind of code is usually used by example code, and not by production code. In a production code, we do not want our service to terminate in case of problems. To handle this, we use the return error method.
The Return Error Method
The return error is used by most of the Go code that we can see. It is also used by Go native libraries. In this method, each function that might fail returns an error in addition to its return values.
package main
import (
"fmt"
"os"
"time"
)
func main() {
for {
err := f1()
if err != nil {
fmt.Printf("got error, but i will stay alive. error is: %v\n", err)
}
time.Sleep(time.Minute)
}
}
func f1() error {
_, err := os.ReadFile("myfile.txt")
if err != nil {
return err
}
return nil
}
And the output is:
got error, but i will stay alive. error is: open myfile.txt: no such file or directory
The problem in this case is that we lost track of the stack trace of the issue. In case of a long call stack, getting a short text message like "division by zero" is useless unless we can somehow related this error with the code location that encountered it. To solve this issue, we use wrapped errors.
The Wrapped Errors Method
Wrapping errors intends to add some information to the error message that assist to find the terms that caused the error, and the code location.
package main
import (
"fmt"
"os"
"time"
)
func main() {
for {
err := f1()
if err != nil {
fmt.Printf("got error, but i will stay alive. error is: %v\n", err)
}
time.Sleep(time.Minute)
}
}
func f1() error {
err := f2()
if err != nil {
return fmt.Errorf("handling f2 failed: %v", err)
}
return nil
}
func f2() error {
_, err := os.ReadFile("myfile.txt")
if err != nil {
return fmt.Errorf("read configuration failed: %v", err)
}
return nil
}
In this method we add explanation for the error whenever we return it, so the output is more verbose:
got error, but i will stay alive. error is: handling f2 failed: read configuration failed: open myfile.txt: no such file or directory
While this is the most common method to handle errors in Go, it has some issues. First, the code gets longer and less readable. Second, the verbose mode is nice, but it has less power than the full stack trace that we got using the panic method. To better handl error, we should use the panic and recover method.
Panic and Recover Method
While seldom used, this is the best method to handle errors in Go. The code is short and readable, and the errors include the full infomation.
We start by adding 2 helper functions to handle errors.
func panicIfError(err error) {
if err != nil {
panic(err)
}
}
func getNiceError(panicError any) error {
stack := string(debug.Stack())
index := strings.LastIndex(stack, "panic")
if index != -1 {
stack = stack[index:]
index = strings.Index(stack, "\n")
if index != -1 {
stack = stack[index+1:]
}
}
return fmt.Errorf("%v\n%v", panicError, stack)
}
func recoverError(recoveredError *error) {
panicError := recover()
if panicError == nil {
recoveredError = nil
} else {
*recoveredError = getNiceError(panicError)
}
}
The panicIfError as it name implies calls to panic in case of error. The recoverError is used only in central locations in our which are considers top-level handling. An example for such handler is a web server executor handler for an incoming request.
The code is now modified to the following.
func main() {
for {
err := f1()
if err != nil {
fmt.Printf("got error, but i will stay alive. error is: %v\n", err)
}
time.Sleep(time.Minute)
}
}
func f1() (recoveredError error) {
defer recoverError(&recoveredError)
f2()
return recoveredError
}
func f2() {
f3()
}
func f3() {
_, err := os.ReadFile("myfile.txt")
panicIfError(err)
}
In this example f1 is considered a top-level handler, so it calls (using defer) to the receover, and also returns error so ti could be handled without terminating the service. Notice the simplicity of the functions f2 and f3, which represent 99% of our code. No error in the return values, and simple flow without conditions. Not only that, we also get a full stack trace in case of error.
got error, but i will stay alive. error is: open myfile.txt: no such file or directory /home/alon/git/cto-proximity/images/analyzer/src/main.go:13 +0x65 main.f3() /home/alon/git/cto-proximity/images/analyzer/src/main.go:61 +0x4e main.f2() /home/alon/git/cto-proximity/images/analyzer/src/main.go:56 +0x17 main.f1() /home/alon/git/cto-proximity/images/analyzer/src/main.go:51 +0x7a main.main() /home/alon/git/cto-proximity/images/analyzer/src/main.go:41 +0x2f
This method simplifies our code, and removes ~ 50% of the code line, while providing more information in case of errors. The performance implications are neglectable.
Final Note
We've added the panic and recover helper functions to our code, and modified the code to use them. We're now working only using the panic and recover method, and it is so simple and fun to use. We just cannot believe that we used any other method before that. I highly recommend you doing the same.
Maybe Go libraries will also accept this idea in the future and will further simplify our code...