Lesson 19 / 25

errgroup for Concurrent Tasks with Errors

Run tasks together, return the first error, cancel the rest.

WaitGroup plus errors plus cancellation

golang.org/x/sync/errgroup provides errgroup.Group. g.Go(func() error) starts a task and g.Wait() blocks until all tasks return, then returns the first non-nil error (later errors are discarded). Created with errgroup.WithContext(ctx), the group also returns a derived context that is cancelled as soon as any task fails (or when Wait returns), so siblings can stop early, provided they pass that context to their I/O. SetLimit(n) bounds concurrency and TryGo starts a task only if a slot is free. The zero errgroup.Group{} works too, without cancellation. If you need every error rather than the first, collect them yourself (for example into a slice by index) and combine with errors.Join.

Failing well and stopping cleanly

Real services need to collect errors from concurrent work, respect rate limits and shut down without losing in-flight requests.

Three ideas: errgroup, rate limiting and graceful shutdown.
Figure 7.1: errors flowing back, requests throttled, shutdown draining work.

Fetching a page of data in parallel

Results go into slots by index, so no mutex is needed.

package main

import (
	"context"
	"fmt"

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

type User struct{ Name string }

func fetchUser(ctx context.Context, id int) (User, error) {
	if id == 3 {
		return User{}, fmt.Errorf("user %d not found", id)
	}
	return User{Name: fmt.Sprint("user-", id)}, nil
}

func fetchAll(ctx context.Context, ids []int) ([]User, error) {
	g, ctx := errgroup.WithContext(ctx)
	users := make([]User, len(ids)) // each goroutine writes only its own index
	for i, id := range ids {
		g.Go(func() error {
			u, err := fetchUser(ctx, id)
			if err != nil {
				return err // cancels ctx for the other tasks
			}
			users[i] = u
			return nil
		})
	}
	if err := g.Wait(); err != nil {
		return nil, err
	}
	return users, nil
}

func main() {
	_, err := fetchAll(context.Background(), []int{1, 2, 3, 4})
	fmt.Println(err) // user 3 not found
}

Use the derived context, not the parent

Shadowing with g, ctx := errgroup.WithContext(ctx) makes it hard to accidentally pass the parent context to tasks, which would stop them noticing a sibling failure.

Quick check: What does g.Wait() return when two tasks in an errgroup fail?

  • nil, because errors are only logged
  • Both errors joined together
  • The last error returned
  • The first non-nil error returned by any task
Answer

The first non-nil error returned by any task — errgroup keeps the first error; use errors.Join yourself if you need all of them.