Golang panics in libraries
Jul 31, 2023Introduction #
When I read about Golang for the first time, I was an active user of languages with exceptions, like Java, PHP, Python, Ruby, etc.
It was obvious that big projects handle their behaviour through exceptions and we can control flow through try/catch
blocks in parent call stack.
Outside languages with exceptions I had some experience with other languages like C or codebases that prohibit exceptions.
Experience without exceptions, usually was less pleasant, because it applied many restrictions and limitations to the codebase.
Additionally, many of these codebases did not support return for multiple values, so the flow control had to be built through returning unsupported values (like -1
for int
s and null for references).
Because of these limitations, there were popular ways to handle these restrictions through reference parameters and returning results through them.
Golang #
There we are! We have met Golang for the first time.
The documentation and articles and bool suggest to use error
return value, instead of exceptions. More over, exceptions are not supported in Golang at all (or almost).
By going through Golang documentation and the codebase, many people notice that there is a panic
with recover
, that can be used as exceptions. These people write many articles, posts, and comments about how panic
s in Go.
But Golang community always criticizes these thoughts and suggests to use error
returned value instead of panic
. Moreover, those people share an opinion that error
handling in Golang is much better than exceptions in other languages.
Let’s try to understand why it is so.
Error handling #
Before we will compare exceptions and error handling from Golang, let’s try to go through the main ways to handle errors.
There are three of them:
- Return error from the function.
- Throw error through current stack of calls until someone will catch it.
- Stop execution of the program.
All these ways are used in many different languages in parallel and non of them is better than others.
Return error from the function #
This way is the first way I’ve seen when I started to learn programming in C and Assembly. It is the most obvious way to handle errors, because we can just agree that some special value will be returned in case of error. In case of C it is -1
for int
s and NULL
for references. Sometimes it cam be some error
parameter that will be filled with error value by reference.
The best thing about this way is that it is very simple and easy to understand. It increases readability of the code and makes it easy to debug by printing a value or putting debugger on the line to see the scope of variables. In case of C it was kinda confusing, since we did not have predictable way to work with errors and we had to check the documentation of each function to understand how it handles errors.
In case of modern languages like Golang, Rust, etc. we have a predefined way to work with error handling by specific error
types and rules on how to use them. It improves readability and simplifies interfaces of functions.
Basically all of the changes around it are just cosmetic that make huge difference, but concept is still the same.
In the diagram above we can see the flow of the program that uses this approach to handle errors. Short explanation of the diagram:
main
function callsfunc1
function.func1
function callsfunc2
function.func2
function returns an error.func1
function checks return values fromfunc2
function and returns an error based onerror
fromfunc1
.main
function checks return values fromfunc1
function and handles theerror
.main
function can continue execution of the program.
The main idea of this approach a software developer should handle errors in each function in the call stack. And they can handle errors on any level of the call stack. If we do not know what to do on a specific level, we can just return an error to the caller and let them handle it.
Pros #
- Easy to understand.
- Easy to debug.
- Easy to use.
Cons #
- In some cases this approach adds a boilerplate code.
- We need to thing about error handling in each call of a function.
- Every function in the call stack should handle errors and return them back to the caller, if needed.
- It can be simple to ignore an error without adding any handling for it.
This handle of errors is used to guarantee that an error won’t be ignored and developer will always aware about an error.
Throw error through current stack of calls until someone will catch it #
The main idea of this approach is to throw an error from a function up to callers stack until someone will catch it. This way is the most popular way to handle errors in languages with exceptions like Java, PHP, Python, Ruby, etc.
By using this approach we can send a error
through call stack without adding any code and knowledge to intermediate levels. It simplifies development and removes requirements to thing about errors on the most levels of the call stack.
Languages that support exceptions have a special keyword to throw an exception and to catch it.
- In case of Java and PHP it is
throw
andtry/catch
blocks. - In case of Python it is
raise
andtry/except
blocks. - In case of Ruby it is
raise
andbegin/rescue
blocks.
But all of them have the same idea to control the execution flow: throw an exception and catch it on the level where we want handle it.
With Go it is a bit different. We do not have exceptions, but we have panic
and recover
functions.
Panics by themself are not control-flow statements, they are closer to Java Error
type or PHP ErrorException
type. They are used to stop execution of the program in case of critical errors that are not related to business flow of the program.
In case of Go we can use panic
to throw an error and recover
to catch it, but we can catch it only in defer functions that are not linear execution code block and applies some limitations on top of it.
In the diagram above we can find the flow of the program that uses this approach to handle errors. Short explanation of the diagram:
main
function callsfunc1
function.func1
function callsfunc2
function.func2
function throws anerror
.main
function catches anerror
and handles it.main
function can continue execution of the program.
This way to handle errors is used to simplify code writing and to reduce boilerplate code on intermediate levels of the call stack.
Pros #
- Easy to write new code.
- Zero boilerplate code on intermediate levels of the call stack.
Cons #
- It is hard to understand where an error was thrown.
- It is hard to debug.
- It is hard to understand where an error will be caught, if it will be caught at all.
- If error was not caught, the program will be stopped during an execution.
Stop execution of the program #
The most radical way to handle errors is to stop execution of the program in case of any error.
In the most languages it is done by using exit
function that stops execution of the program and returns an error code to the caller.
To handle errors there assert
function that checks a condition and stops execution of the program if it is not met.
There is no way to recover the execution of the program after exit
or assert
functions were called. The one way error handling is used to stop execution in case of some critical errors that are not related to business flow of the program, but they also cannot be handled by the program.
The simplest example of such error is a unsupported value of a parameter in command line interface. In this case we can just stop execution of the program and print an error message to the user.
Let’s review the flow of the program that uses this approach to handle errors. The diagram above shows the flow. In this diagram we added OS level that represents the operating system that runs the program from an entry point1.
Short explanation of the diagram:
os
calls an entry point of a program, usually by calling themain
function.main
function callsfunc1
function.func1
function callsfunc2
function.func2
function checks a condition and stops execution of the program.os
receives an error code from the program and handles it and stops execution.
This approach is used to stop execution of the program in case of critical errors that are not related to business flow of the program.
Pros #
- Easy to stop an execution of the program.
- Guarantees that the program will not continue execution in case of critical errors.
- Application will stop very fast.
Cons #
- Since the program stops execution with a code, it is hard to debug without logs.
- The program stops execution, that is not what we usually want.
Panic and recover in Go #
We went through different ways to handle errors in the program. We have successfully reviewed the flows and pros and cons of each approach. During the review we also mentioned that Go supports all of them, but it’s important to jump a little bit deeper into it.
Error type in Go #
Go has a special interface to represent an error. It is called error
and it is defined in the builtin package.
type error interface {
Error() string
}
We can use this interface to define our own error types and to use them in our programs.
// NewMyError factory method that creates a new MyError.
func NewMyError(message string) error {
return &MyError{message: message}
}
type MyError struct {
message string
}
func (e *MyError) Error() string {
return e.message
}
The error
type is expected to be the latest return value of a function.
func func1() error {
return NewMyError("error message")
}
func func2() (int, string, bool, error) {
return 0, "", false, NewMyError("error message")
}
And we also expect that the caller will check the error and handle it. There is a popular linter that checks it for us: errcheck.
Also, errors can be stacked in a chain, wuth two interfaces:
interface {
Unwrap() error
}
interface {
Unwrap() []error
}
With errors package that contains useful helpers to work with errors.
Errors is the main way to handle errors in Go and it is used in most of the cases and suggested to be used when possible.
Note: If you can use
error
type, use it. Do not even thing to use any other error handling approaches.
Panic and recover #
We went through throw error handling flow. Although exceptions are very popular in other languages, Go does not support them.
In Go we have panic
and recover
functions that are used to handle critical errors.
The code that uses panic
usually means that something went wrong, very wrong.
When we throw panic
we stop execution of current code and start to unwind the stack of the program, with no call stack code execution.
But all defers are still executed, even for functions that are not expecting to handle panics.
We will see it in the following example.
package main
import "fmt"
func main() {
defer func() { fmt.Println("Defer main") }()
fmt.Println("Hello world")
func1()
fmt.Println("Not executed code main")
}
func func1() {
defer func() { fmt.Println("Defer func1") }()
func2()
fmt.Println("Not executed code func1")
}
func func2() {
defer func() { fmt.Println("Defer func2") }()
panic("Test panic")
fmt.Println("Not executed code func2")
}
// Output:
// Hello world
// Defer func2
// Defer func1
// Defer main
// panic: Test panic
//
// goroutine 1 [running]:
// main.func2()
// /tmp/sandbox493636343/prog.go:26 +0x49
// main.func1()
// /tmp/sandbox493636343/prog.go:19 +0x3f
// main.main()
// /tmp/sandbox493636343/prog.go:11 +0x7d
//
// Program exited.
Since we are using panic
in func2
function, we stop execution of the program and start to unwind the stack. All defer
s are executed from func2
, func1
and main
. Because we have not recover
ed from the panic, the program stops execution and prints a panic message with stack trace.
We can use recover
to handle panics and sometimes we will, but as good practice let’s agree that we will not use panic
s as a control flow of the program.
Stop execution #
Sometomes we want to just stop execution of the program. For example, we have a program that is a CLI tool and we want to stop execution of the program if the user provided wrong parameters.
We can use os.Exit
to stop execution of the program.
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Please provide a name")
os.Exit(1)
}
fmt.Println("Hello", os.Args[1])
}
// > ./main test
// Hello test
// > ./main
// Please provide a name
// exit status 1
os.Exit
will close program emidiately and will not execute any defer
s. This way is even more dangerous than panic
and should be used only in cases when we know what do we do.
Note: Do not use
os.Exit
unless you know what you are doing.
Panic in libraries #
Finally, let’s talk about panics in libraries. We have already mentioned that we should not use panic
as a control flow of the program.
But, why are libraries so unique?
Libraries are created to be used by other programs and people. When people use libraries, they expect that the library will not stop execution of the program. If there are any errors, library will return an error and the caller will know about it based on the interface and can handle it.
This approach helps to be sure that even without careful reading of the documentation, the caller will not be surprised by the behavior of the library. And it is a good practice to follow. Error handling through return value helps to be Go code more predictable and stable. This simple rule helps to make programs to be unfailable on production.
One exception and informal agreement #
Although we have a rule that we should not use panic
in libraries, there is one exception.
Sometimes libraries return error
value and we know that caller in the most cases will not handle it and just panic.
There are some examples:
regexp.Compile
- it is expected that the caller will provide a valid regular expression.- If the regexp is set in constant code
regexp.Compile("invalid regexp")
, we will just panic and fix the code. - If the regexp is provided as an input from the user, we should handle the error ro notify user and move forward with execution.
- If the regexp is set in constant code
uuid.NewRandom
(source code) Where we create a random UUID generator. If we have a problem with the random generator, it will return an error.- In the most cases we will panic, because we expect that random generator will work.
- In some cases we will handle the error and use some fallback generator (for example from an external service).
As you can see, there are some cases when we expect from the caller to panic in the most cases of using a library.
We ALWAYS provide the interface with error
returned as a value, but since we know that in the most cases the caller will not handle the error, we can provide a helper function that will panic in case of error.
Let’s see an example:
package main
import (
"errors"
"fmt"
)
func main() {
fmt.Println(Compile("test")) // Will return "test".
fmt.Println(Compile("")) // Will return the error.
fmt.Println(MustCompile("test")) // Will return "test".
fmt.Println(MustCompile("")) // Will panic
}
func Compile(regexp string) (string, error) {
if regexp == "" {
return "", errors.New("critical error, empty regexp")
}
return regexp, nil
}
func MustCompile(regexp string) string {
res, err := Compile(regexp)
if err != nil {
panic(err)
}
return res
}
// Output:
// test <nil>
// critical error, empty regexp
// test
// panic: critical error, empty regexp
//
// goroutine 1 [running]:
// main.MustCompile(...)
// /tmp/sandbox2091129551/prog.go:26
// main.main()
// /tmp/sandbox2091129551/prog.go:12 +0x178
//
// Program exited.
There is a good practice or/and agreement that if we have a helper-function that throws a panic, we should name it with Must
prefix. For example: MustCompile
, MustNew
, MustOpen
and so on.
Note: Do not use
panic
in libraries except you provide a helper function withMust
prefix.
Conclusion #
In Go we have all tools to handle errors. But the preferrable way to use errors is to return them as a value and handle them in the caller code.
If a developer of a library expects that in the most cases the caller will not handle the error, they can provide a helper function with Must
prefix that will panic in case of error. But the main interface of the library MUST still return an error.
Let’s follow good practices and make our code more stable and predictable.
In computer programming, an entry point is the place in a program where the execution of a program begins, and where the program has access to command line arguments. ↩︎