Cover image

Converting Panics to Errors in Go Applications

Foto von Anton Darius auf Unsplash

Golang is a popular language that makes it easy for beginners to start writing concurrent applications. Often, panics (or exceptions) are processed in the same way as errors, leading to duplicated handling for both. Errors should ideally be handled in a single place to eliminate redundancy in managing panics and errors.

This article explores various techniques to recover from panics in different situations.

In most Golang conventions, functions that return an error object are commonly used. Let’s explore this with a simple example:

// panic.go
package main

import (
  "fmt"
  "os"
)

func main() {
  err := run()
  if err != nil {
    fmt.Printf("error: `%+v`\n", err)
    os.Exit(1)
  }
  fmt.Println("success!")
}

func run() error {
  return nil
}

Ensure that it works: go run panic.go

Handling Exceptions in a Function

Introduce a bit of distubance and recover from it with defer and recover.

 func run() error {
+ defer func() {
+   if excp := recover(); excp != nil {
+     fmt.Printf("catch: `%+v`\n", excp)
+   }
+ }()
+
+ fmt.Println("Panicking!")
+ panic("boom!")
+
  return nil
 }

The program will output:

Panicking!
catch: `boom!`
success

This approach handles unexpected exceptions, but existed with success. However, it’s desirable to process errors and exceptions in the main function in the same way, indicating the presence of an error.

Using the rule from “Defer, Panic, and Recover”1: 3. Deferred functions may read and assign to the returning function’s named return values..

 import (
+ "errors"
  "fmt"
  "os"
 )

...

-func run() error {
+func run() (err error) {
  defer func() {
    if excp := recover(); excp != nil {
      fmt.Printf("catch: `%+v`\n", excp)
+     switch v := excp.(type) {
+     case string:
+       err = errors.New(v)
+     case error:
+       err = v
+     default:
+       err = errors.New(fmt.Sprint(v))
+     }
+   }
  }()
 
  fmt.Println("Panicking!")
  panic("boom!")
 
- return nil
+ return
 }

make sure change the function signature to has a named return value err error and return without a value.

The recover() function returns an interface{} object. If there’s an exception that isn’t an error type, it initializes error object. Once you have the correct type, assign to err instance.

This modified program will output:

Panicking!
catch: `boom!`
error: `boom!`
exit status 1

The exit code now indicates that the program finished with error code. As an experiment, you can change the panic argument to other types such as error or int.

Handling Exceptions in a Goroutine

Wrap the panic statement with the go statement and give it some time to allow the scheduler to pick up the goroutine:

import (
  "errors"
  "fmt"
  "os"
+ "time"
 )
 
 ...
 
- fmt.Println("Panicking!")
- panic("boom!")
+ blocker := make(chan struct{})
+ go func() {
+   fmt.Println("Panicking!")
+   panic("boom!")
+   blocker <- struct{}{}
+ }()
+
+ select {
+ case <-blocker:
+   panic("this branch should not happen")
+ case <-time.After(time.Second):
+   fmt.Printf("goroutine timeouted! err = `%+v`\n", err)
+ }
 
  return
 }

In this case, the panic wasn’t caught in the child goroutine:

Panicking!
panic: boom!

goroutine 34 [running]:
main.run.func2()
	.../panic.go:124 +0x68
created by main.run in goroutine 1
	.../panic.go:122 +0xa4
exit status 2

As a solution, you can duplicate the recovery inside the goroutine function:

  blocker := make(chan struct{})
  go func() {
+   defer func() {
+     if excp := recover(); excp != nil {
+       fmt.Printf("catch in goroutine: `%+v`\n", excp)
+       switch v := excp.(type) {
+       case string:
+         err = errors.New(v)
+       case error:
+         err = v
+       default:
+         err = errors.New(fmt.Sprint(v))
+       }
+     }
+   }()
+
    fmt.Println("Panicking!")
    panic("boom!")
    blocker <- struct{}{}

Output:

Panicking!
catch in goroutine: `boom!`
goroutine timeouted! err = `boom!`
error: `boom!`
exit status 1

The modified function run would be as follows:

func run() (err error) {
	blocker := make(chan struct{})
	go func() {
		defer func() {
			if excp := recover(); excp != nil {
				fmt.Printf("catch in goroutine: `%+v`\n", excp)
				switch v := excp.(type) {
				case string:
					err = errors.New(v)
				case error:
					err = v
				default:
					err = errors.New(fmt.Sprint(v))
				}
			}
		}()

		fmt.Println("Panicking!")
		panic("boom!")
		blocker <- struct{}{}
	}()

	select {
	case <-blocker:
		panic("this branch should not happen")
	case <-time.After(time.Second):
		fmt.Printf("goroutine timeouted! err = `%+v`\n", err)
	}

	return
}

While the resulting code may not be optimal, propagating exceptions for improved processing is crucial. This article explores various Go language tricks, to enhance error handling and exception management in your programs.

I’ve uploaded the video, along with my clarification, to Vimeo2.

thumbnail

References

  1. The Go Blog: Defer, Panic, and Recover 

  2. Video: Walkthrough Converting Panics to Errors in Go Applications