Error handling in goroutines with errgroup

Golang made life easier with goroutine, however, sometimes it's difficult to handle errors that happened inside a goroutine effectively. For example, imagine you have an array of some kind of actions and wanted to run a specific function on each one of them. On the other hand, in case of an error, you want to propagate that error to the higher function.

Let's explain it in the code, Imagine that we have a set of runners and each runner has a Handle function.

type HandlerFunc func(input string) error
type Runner struct {
    Name   string
    Handle HandlerFunc
}

I also like to define another type named Runners. It's just a simple wrapper around arrays of runners

type Runners []Runner

hence I can define a function that runs through all runners like this:

func (r Runners) Execute() error {
    for _, runner := range r {
        if err := runner.Handle(runner.Name); err != nil {
            return err
        }
    }
    return nil
}

and finally, define some runners and execute them:

func main() {
    runners := Runners{
        Runner{
            Name: "1",
            Handle: func(input string) error {
                fmt.Printf("runner %s is running\n", input)
                return nil
            },
        },
        Runner{
            Name: "2",
            Handle: func(input string) error {
                return fmt.Errorf("something bad happened in runner [%s]", input)
            },
        },
        Runner{
            Name: "3",
            Handle: func(input string) error {
                fmt.Printf("runner %s is running\n", input)
                return nil
            },
        },
    }

    err := runners.Execute()
    if err != nil {
        fmt.Printf("execution failed: %v", err)
    }  
}

By running this piece of code I get this output: runner 1 is running. The problem is we didn't run the third runner. So in this case we print errors and continue running the Execution but we cannot propagate the error to the higher function!

func (r Runners) Execute() error {
    for _, runner := range r {
        if err := runner.Handle(runner.Name); err != nil {
      fmt.Printf("error happened in runner [%s]: %v", runner.Name, err)
        }
    }
    return nil
}

Now, What if we want to run each runner in a different goroutine with the exact same scenario? Let's change a code a little bit to see if it works.

First thing first we add a go behind the function call. So our Execute function should be something like it:

func (r Runners) Execute() error {
    for _, runner := range r {
        go func(runner Runner) error {
            if err := runner.Handle(runner.Name); err != nil {
                return err
            }
            return nil
        }(runner)
    }
    return nil
}

However, As you probably know if we run the program again, there will be nothing in output, because we never said the application to wait until the goroutines finish their work. for sake of simplicity, let's just add a Sleep to the main function:

err := runners.Execute()
time.Sleep(3 * time.Second)
if err != nil {
    fmt.Printf("execution failed: %v", err)
}

Now, let's run it again. this time the output should be something like this:

runner 1 is running
runner 3 is running

OK, it's not OK! The execution worked well because we could execute runners 1 and 3, however, we still didn't do anything about the error.

Welcome to errgroup

Now, it's time to solve the problem with errgroup. It's REALLY simple and easy. It works like waitgroups under the sync package. Honestly, it's using wait groups behind the scene but since the mentioned scenario is quite common, errgroup make life easier for us!

If you're familiar with wait group, You should know what wg.Done() and wg.Wait() mean. errgroup offeres same thing. Let's make things clear in code. First, we declare an g variable

g := new(errgroup.Group)

and run our execute function inside a goroutine with Go func. so our Execute function turns to this:

func (r Runners) Execute() {
    for _, runner := range r {
        rx := runner
        g.Go(func() error {
            return rx.Handle(rx.Name)
        })
    }
}

the Go function gives and func which returns an error, if this error is not nil you will have that in the returns of the Wait function.

Here's the Wait func:

runners.Execute()
err := g.Wait()
if err != nil {
    fmt.Printf("execution failed: %v", err)
}

As you see, we don't need time.Sleep() anymore, because the Wait func, waits until the last goroutine gets the result and returns the first error that happened. If all functions run without error, it returns nil

the output is something like it:

runner 3 is running
runner 1 is running
execution failed: something bad happened in runner [2]

and here's the complete code:

package main

import (
    "fmt"

    "golang.org/x/sync/errgroup"
)

var g errgroup.Group

type HandlerFunc func(input string) error

type Runner struct {
    Name   string
    Handle HandlerFunc
}

type Runners []Runner

func (r Runners) Execute() {

    for _, runner := range r {
        rx := runner
        g.Go(func() error {
            return rx.Handle(rx.Name)
        })
    }
}

func main() {
    runners := Runners{
        Runner{
            Name: "1",
            Handle: func(input string) error {
                fmt.Printf("runner %s is running\n", input)
                return nil
            },
        },
        Runner{
            Name: "2",
            Handle: func(input string) error {
                return fmt.Errorf("something bad happened in runner [%s]", input)
            },
        },
        Runner{
            Name: "3",
            Handle: func(input string) error {
                fmt.Printf("runner %s is running\n", input)
                return nil
            },
        },
    }
    runners.Execute()
    err := g.Wait()
    if err != nil {
        fmt.Printf("execution failed: %v", err)
    }
}

No Comments Yet