Dealing with errors is not trivially simple. Error handling requirements are rarely deliberated on while discussing functional requirements, yet error handling is a vital part of software development.
In GO, error conditions are returned as a method return value(s). In my opinion, it is useful to consider error conditions as part of the main flow — it puts the onus on developers to address error handling while writing functional code. This paradigm is very different from what other programming languages (such as Java) offer — where exceptions are a totally different flow. While this different style makes the code more readable, it also brings out new challenges.
This blog entry discusses six techniques to handle errors, retries, and serviceability. While few of the ideas are trivial, others are not as popular.
So, let's get started with the list:
Align to the left
The best strategy to handle the error is to check the error and return from the function immediately. It is okay to have multiple error return statements in a function — in fact, it is the sensible choice. [1]
For example, the following code snippet shows how handling a happy scenario using if err == nil, leads to nested if checks.
// Handling Happy case first - leading to nested if checks...
func example() error {
err := somethingThatReturnsError()
if err == nil {
//Happy processing
err = somethingElseThatReturnsError()
if err == nil {
//More Happy processing
} else {
return err
}
} else {
return err
}
}The above logic can be aligned to left by aligning logic to the left:
func ABetterExample() error {
err := somethingThatReturnsError()
if err != nil {
return err
}
// Happy processing
err = somethingElseThatReturnsError()
if err != nil {
return err
}
// More Happy processing
}Retry recoverable Errors
Few recoverable errors deserve retries — network glitches, IO operations, etc. can be recovered with simple retries.
The following package can help resolve the pain with retries.
// An operation that may fail.
operation := func() error {
return nil // or an error
}
err := Retry(operation, NewExponentialBackOff())
if err != nil {
// Handle error.
return
}Exponential Backoff means that the retry interval is increased exponentially — a sensible choice for most of the network/IO failures.
Wrapping Errors
The default error package is limited — error context details can often be lost. For example,
func testingError2() error {
return errors.New("New Error")
}
func testingError(accountNumber string) error {
err := testingError2()
if err != nil {
return err
}
return nil
}
func main() {
err := testingError("Acct1")
logrus.Error("Error occurred", fmt.Sprintf("%+v", err))
}In this case, the error instance received by the main function doesn't have information that it happened for account Acct1. It is possible to log accountNumber in function testingErrror but with the current package errors it is not possible to pass on that information to the main function.
That is where github.com/pkg/errors comes in. This library is compatible with errors but brings in some cool capabilities.
func testingError2() error {
return errors.New("New Error")
}
func testingError(accountNumber string) error {
err := testingError2()
if err != nil {
return errors.Wrap(err, "Error occurred while processing Card Number "+accoutNumber)
}
return nil
}
func main() {
err := testingError("Acct1")
logrus.Error("Error occurred", fmt.Sprintf("%+v", err))
}With github.com/pkg/errors you also some additional useful functions — errors.Unwrap and errors.Is
Logging Strategies
Golang's default package log doesn't provide the ability to log with the logging level. There are many other choices:
- Glog: https://github.com/golang/glog
- Logrus: https://github.com/sirupsen/logrus
- Zap: https://github.com/uber-go/zap
Logrus and Zap also provide the capability to structure log output — this is a very handy capability as it provides developers the ability to add context to the error log message.
func example(accountNumber string) error {
logrus.SetFormatter(&logrus.JSONFormatter{})
ctxFields := logrus.Fields{
"accountNumber": accountNumber,
"appname": "my-app",
}
//Happy processing
err := errors.New("Some test error while doing happy processing")
if err != nil {
logrus.WithFields(ctxFields).WithError(err).Error("ErrMsg")
return err
}
return nil
}Structured log output will look like:
{"accountNumber":"ABC","appname":"my-app","error":"Some test error while doing happy processing","level":"error","msg":"ErrMsg","time":"2009-11-10T23:00:00Z"}Another key aspect of logging is the ability to get log stack trace. If you use github.com/pkg/errors, you could
logrus.Error("Error occurred", fmt.Sprintf("%+v", err))And you will get an error stack trace like below:
main.testingError2
/home/nayars/go/src/github.com/nayarsn/temp.go:12
main.testingError
/home/nayars/go/src/github.com/nayarsn/temp.go:25
main.main
/home/nayars/go/src/github.com/nayarsn/temp.go:39
runtime.main
/usr/lib/go-1.15/src/runtime/proc.go:204
runtime.goexit
/usr/lib/go-1.15/src/runtime/asm_amd64.s:1374Zap is buffered and optimized for performance. [2]
Error Checks
Treating error as value is good — it is explicit and explicit makes lots of sense. But it can also provide opportunities for developers to skip. For example,
func testingError(accoutNumber string) error {
var err error
_ = errors.New("errors.New with _"
errors.New("errors.New not capturing return")
return err
}The above example shows that the application programmer is two errors returned by errors.New statements. And this can happen intentionally or unintentionally.
Luckily, there is a linter utility that can help you.
Once you have the linter installed, you can simply do the following:
errcheck -blank ./...And you will get output like:
temp.go:16:2: _ = errors.New("Error capturing return using _")
temp.go:18:12: errors.New("Error not capturing return")This can be added as part of tne CD CI pipeline to ensure that the application developers don't miss this part.
errchecis a part of Go linters aggregator utility — https://golangci-lint.run/
Multiple Errors
You have scenarios where you have multiple errors — they are part of then same go routine and you don't want to stop processing — but rather continue processing and record all the errors. There is a library just for that:
Here is a simple example:
func step1() error {
return errors.New("Step1")
}
func step2() error {
return errors.New("Step2")
}
func main() {
var result error
if err := step1(); err != nil {
result = multierror.Append(result, err)
}
if err := step2(); err != nil {
result = multierror.Append(result, err)
}
fmt.Println(multierror.Flatten(result))
}Similarly, for multiple go routines, following library can be used:
Conclusion
I know that the above list is not exhaustive. And for a few of you, it would be all trivial — but hopefully, for some of you, it has contributed to your repertoire of error handling techniques. Please comment in the "Comments Section" if you have any other useful ideas.
References
[1] https://medium.com/@matryer/line-of-sight-in-code-186dd7cdea88
[2] https://medium.com/a-journey-with-go/go-how-zap-package-is-optimized-dbf72ef48f2d