Our apps aren't perfect, they will throw errors. Either they throw errors because of code we wrote or because of code written in a package we are using. Sometimes, that's even what the code is supposed to do when we feed it bad input.
Regardless, our users expect us to handle these errors. Also, for our own sake, we need to log these errors in such a way that we can fix those errors, if they are unexpected.
There are two major ways to handle errors in Go:
-
panic/recover.
panic
andrecover
are language constructs. If you know anther programming language, they function pretty much like throw and catch. What does it mean though? It means there's an error, with an error message and stack trace that we can catch and recover from or let it crash the program. -
error pattern. This is referred to as the idiomatic Go way of doing things. The idea is to return a value and an error object from a function call. If an error occurred, it contains an error object or
nil
, if no error.
Series
- Your first program, you are here
- Variables
- If and else
- Conversions
- Loops
- User input
- Functions
- Error handling, you are here
Crash the program with panic()
Let's take this function Divide()
:
func Divide(nominator int, divider int) float32 {
if divider == 0 {
panic("can't divide by 0")
}
return float32(nominator) / float32(divider)
}
It has an if
check. If Divider()
is 0
then it calls panic()
. So what happens then? You see something similar to:
panic: can't divide by 0
goroutine 1 [running]:
main.Divide(...)
/<path>/panic.go:20
main.main()
/<path>/panic.go:33 +0x96
exit status 2
At the top is the error string you sent to panic()
, the string "can't divide by 0". Then you have the stack trace, entries that indicate where the error started. Read it from the bottom, the error started on line 33 but what really did it happened at line 20, which is this row:
panic("can't divide by 0")
Ok, so we have a way to protect ourselves from input values that we don't want. But crashing the program, is that really necessary? In some cases, it is, in some cases it isn't. For the latter cases we have recover()
.
Capture the error with recover()
Using recover()
is about capturing an error so our program can continue. We need to learn about a concept though before proceeding. That concept is defer
. defer
is a language construct that delays the execution of a function until the nearby function returns. Here's an example:
defer fmt.Println("second")
fmt.Println("first")
Running this program, you should see:
first
second
See
defer
as the last thing that happens.
How is this useful in the context of capturing an error? Capturing errors, if you want to capture it, is something you want to do as the last thing that happens. Take our Divide()
function again:
func Divide(nominator int, divider int) float32 {
defer errorHandler()
if divider == 0 {
panic("can't divide by 0")
}
return float32(nominator) / float32(divider)
}
Note how it now has a line in it that says defer errorHandler()
. It will be run the last thing that happens. Depending on the value of divider
, it will either call panic()
or call the return
statement as the last thing.
Ok, so what does errorHandler()
look like?
func errorHandler() {
if r := recover(); r != nil {
fmt.Println("Recovered ", r)
}
}
In errorHandler()
, we invoke recover()
and assign it to variable r
and then we test it for nil
. If it's NOT nil
then we have an error and we print it out. If it is nil
then the user notices nothing.
Exercise - add error handling
In this exercise, we'll add error handling to our program.
- Create a file panic.go and give it the following content:
package main
import (
"fmt"
)
// func errorHandler() {
// if r := recover(); r != nil {
// fmt.Println("Recovered ", r)
// }
// }
func Divide(nominator int, divider int) float32 {
// defer errorHandler()
if divider == 0 {
panic("can't divide by 0")
}
return float32(nominator) / float32(divider)
}
func main() {
no := Divide(10, 0)
fmt.Println(no)
no = Divide(10, 1)
fmt.Println(no)
}
- Run the program
go run panic.go
go run panic.go
You should see output similar to:
panic: can't divide by 0
goroutine 1 [running]:
main.Divide(...)
/<path>/panic.go:20
main.main()
/path/panic.go:33 +0x96
exit status 2
Note how these two statements was never run as the program crashed:
no = Divide(10, 1)
fmt.Println(no)
- Uncomment the commented out part and run the app again.
You should now see following output:
Recovered can't divide by 0
0
10
Congrats, you've managed to implement error handling with panic()
and recover()
.
Improve the error handling
There are steps we can take to improve the error handling. So far, we're printing back an error message. Imagine if someone read this error from a log file, would they be able to understand where things went wrong and possibly fix the input data or the code itself?
There are a couple of things we can do:
- Inspect the error. Our error didn't only come with an error message, there's a stacktrace as well.
- Logging. If we want someone to work with these errors, we should look at logging them to a file.
Inspect the error with runtime/debug
There's a library runtime/debug
. With this library, you can find out more about an error when it's thrown, information like stacktrace, where the error originated. There's a function Stack()
that produces the stacktrace. Here's how to use it:
debug.Stack()
Log the error with log
While runtime/debug
is able to produce a stacktrace, what would be really useful is logging all this error information to a file. To log to a file, use the os
and log
package like so:
f, err := os.OpenFile("logs", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
log.Println(err)
}
log.SetOutput(f)
Exercise - improve error logging
Let's improve our panic.go file by adding error logging:
- Locate the
errorHandler()
and change it to the following:
func errorHandler() {
if r := recover(); r != nil {
log.Println(r, string(debug.Stack()))
}
}
- Ensure that panic.go looks like so:
package main
import (
"fmt"
"log"
"os"
"runtime/debug"
)
func errorHandler() {
if r := recover(); r != nil {
log.Println(r, string(debug.Stack()))
}
}
func Divide(nominator int, divider int) float32 {
defer errorHandler()
if divider == 0 {
panic("can't divide by 0")
}
return float32(nominator) / float32(divider)
}
func main() {
f, err := os.OpenFile("logs", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
log.Println(err)
}
log.SetOutput(f)
log.Println("starting program")
no := Divide(10, 0)
fmt.Println(no)
no = Divide(10, 1)
fmt.Println(no)
f.Close()
}
- Run this program:
go run panic.go
You should see this output:
0
10
It was able to run all statements without being affected by the error that was thrown.
- Inspect the logs file that was just created, it should have content similar to the below:
2022/03/11 15:03:59 starting program
2022/03/11 15:03:59 can't divide by 0 goroutine 1 [running]:
runtime/debug.Stack(0xc000111d30, 0x10b1b40, 0x10eae78)
/usr/local/Cellar/go/1.16/libexec/src/runtime/debug/stack.go:24 +0x9f
main.errorHandler()
/<path>/panic.go:14 +0x5b
panic(0x10b1b40, 0x10eae78)
/usr/local/Cellar/go/1.16/libexec/src/runtime/panic.go:965 +0x1b9
main.Divide(0xa, 0x0, 0x0)
/<path>/panic.go:21 +0xa5
main.main()
/<path>/panic.go:34 +0x115
Use the error pattern with the errors
package
Other languages tend to use Exceptions to signal that something is wrong.
Go has a different and idiomatic approach. It wants you to create errors as return values to a function, next to the actual value being returned. You are then expected to inspect a function and see if it returns an error.
There's an errors
package that can help us with the above approach.
Define an error
To define an error, we call the New()
function with a string describing the error, here's an example:
var NoTooSmall = errors.New("the number is too small")
Next, let's look at how to add the error to a function.
Return an error
Let's start with function that uses a panic()
as error handling:
func ReturnPositive(no int) int {
if no > 0 {
return no
} else {
panic("No too small")
}
}
We can improve this function, by ensuring it returns the result and an error at all times, like so:
func ReturnPositive(no int) (int, error) {
if no > 0 {
return no, nil
} else {
return 0, NoTooSmall
}
}
Note in the if
clause that it returns no
and nil
when everything is fine. For the else
, it returns a bogus value and our error NoTooSmall
.
Inspect the result
Let's see how we would call the ReturnPositive()
function and use this new pattern we established:
no, err := ReturnPositive(-2)
if err != nil {
fmt.Println("error: ", err)
} else {
fmt.Println("value:", no)
}
What you are seeing above is how we use an if
clause to check for errors, if so, print out. On the else
, we have our actual value.
Summary
In this article, you saw how you could cause and handle errors with panic()
and recover()
. Another, more idiomatic approach i by using an error pattern, in which you return the result and an error. Then you need to inspect the result to see if an error was produced or if you can safely use the returned value.