Skip to content

The first error cancels the rest

Level: 301 · you start several calls for one request, and want them all stopped when one fails

One line: errgroup can be built from a sync.WaitGroup, a sync.Once and context.WithCancelCause. The first task to fail records its error and cancels the shared context with that error as the cause; the other tasks see ctx.Done() and give up; and Wait returns that first error, not the errors the canceled tasks returned after it.

golang.org/x/sync/errgroup packages this pattern. errgroup.WithContext returns a group and a context, Go starts a func() error, and Wait waits for all of them and returns the first non-nil error. Its documentation says the context is canceled the first time a function passed to Go returns an error, or the first time Wait returns, whichever comes first. The package lives outside the standard library, so the program below builds a group type with the same three calls from sync and context.

The output

Verified output of first_error_cancels_the_rest_go.go — regenerated by tools/run_examples.py, never hand-typed.

1. price and stock wait on ctx; reviews fails at once
   price    returned:  price: gave up: reviews: service unavailable
   stock    returned:  stock: gave up: reviews: service unavailable
   reviews  returned:  reviews: service unavailable
   Wait returned:     reviews: service unavailable
   ctx.Err():         context canceled
   context.Cause:     reviews: service unavailable
2. no task fails
   ctx.Err() before:  <nil>
   Wait returned:     <nil>
   ctx.Err() after:   context canceled
first_error_cancels_the_rest_go.go

first_error_cancels_the_rest_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// The first error cancels the rest: what golang.org/x/sync/errgroup does with
// WithContext, built from context.WithCancelCause and a sync.WaitGroup. Three
// tasks fetch the parts of a product page. The reviews service fails at once;
// the price and stock tasks stand in for slow calls, and return only when their
// context is canceled. The first error cancels that context, with itself as the
// cause, and it is the error Wait returns.
//
//  go build first_error_cancels_the_rest_go.go && ./first_error_cancels_the_rest_go
package main

import (
    "context"
    "errors"
    "fmt"
    "sync"
)

// group is errgroup.Group, as returned by errgroup.WithContext, cut down to
// Go and Wait.
type group struct {
    wg      sync.WaitGroup
    cancel  context.CancelCauseFunc
    errOnce sync.Once
    err     error
}

func withContext(parent context.Context) (*group, context.Context) {
    ctx, cancel := context.WithCancelCause(parent)
    return &group{cancel: cancel}, ctx
}

// Go runs task in a new goroutine. The first task to return an error records
// it and cancels the group's context, with that error as the cause.
func (g *group) Go(task func() error) {
    g.wg.Go(func() {
        if err := task(); err != nil {
            g.errOnce.Do(func() {
                g.err = err
                g.cancel(err)
            })
        }
    })
}

// Wait waits for every task, then returns the first error, or nil. Like
// errgroup's Wait, it also cancels the context, so nothing started from it
// outlives the group.
func (g *group) Wait() error {
    g.wg.Wait()
    g.cancel(g.err)
    return g.err
}

func main() {
    fmt.Println("1. price and stock wait on ctx; reviews fails at once")
    g, ctx := withContext(context.Background())
    names := []string{"price", "stock", "reviews"}
    returned := make([]error, len(names)) // each task writes only its own slot

    for i, name := range names[:2] {
        g.Go(func() error {
            <-ctx.Done() // a slow call that gives up when ctx is canceled
            returned[i] = fmt.Errorf("%s: gave up: %w", name, context.Cause(ctx))
            return returned[i]
        })
    }
    g.Go(func() error {
        returned[2] = errors.New("reviews: service unavailable")
        return returned[2]
    })

    err := g.Wait()
    for i, name := range names {
        fmt.Printf("   %-8s returned:  %v\n", name, returned[i])
    }
    fmt.Printf("   Wait returned:     %v\n", err)
    fmt.Printf("   ctx.Err():         %v\n", ctx.Err())
    fmt.Printf("   context.Cause:     %v\n", context.Cause(ctx))

    fmt.Println("2. no task fails")
    g, ctx = withContext(context.Background())
    for range 3 {
        g.Go(func() error { return nil })
    }
    fmt.Printf("   ctx.Err() before:  %v\n", ctx.Err())
    fmt.Printf("   Wait returned:     %v\n", g.Wait())
    fmt.Printf("   ctx.Err() after:   %v\n", ctx.Err())
}

Reading the output

The run cannot come out another way. reviews returns its error at once. price and stock stand in for slow calls that take a context: each blocks on <-ctx.Done(), so neither can return before the context is canceled. The context is canceled only by the first error, or by Wait, which cannot return before they do. So the first error is the one from reviews on every run, whichever goroutine the scheduler happens to start first. The tasks were started in the order price, stock, reviews: "first" means first to fail, not first started.

price returned: price: gave up: reviews: service unavailable. A canceled task returns an error of its own, here one that wraps context.Cause(ctx) with %w. Those errors are not nil, but they come after the first one, so errOnce ignores them, and Wait returned shows the error from reviews alone (Once runs exactly once).

ctx.Err() and context.Cause. Err says only that the context was canceled. Cause says why: the context documentation says that calling the cancel function with a non-nil error records that error, and Cause returns it (Cancel with a cause). That is what lets price report why it gave up, not only that it did.

Why the group keeps its own first error. A CancelCauseFunc already keeps the first cause: its documentation says it does not set the cause if the context has already been canceled. But Cause would also report a cancellation that came from a parent context, or from Wait's own cancel, and neither of those is an error that a task returned. So Go records the first task error under a sync.Once and cancels with it, and Wait returns what was recorded.

2. No task fails. Wait returns nil, and the context, which was not canceled before, is canceled after. Wait cancels it the way errgroup's does, so that anything started from that context stops along with the group. Calling the cancel function with nil sets the cause to context.Canceled, which is the context canceled printed. Do not pass that context to work that has to outlive the group.

What this does not do. Cancellation is a request. A task that never looks at ctx runs to its end, and Wait waits for it (Cancel reaches every goroutine). And a task must not panic: the documentation of WaitGroup.Go says so, and a panic in any goroutine ends the whole program (A panic ends the whole program).

WithCancelCause and Cause were added in Go 1.20, and WaitGroup.Go in Go 1.25.

What to do

  • Start the calls for one request in a group with one context, and pass that context into every call.
  • Return the first error, and treat the rest as its consequences. Wrap context.Cause(ctx) into a canceled task's error, so that a log line says what the task gave up for.
  • Write every task to return soon after ctx.Done() is closed. The group can only ask.
  • Use errgroup itself where golang.org/x is allowed. It saves writing the group type, and its SetLimit adds the limit from A buffered channel as a semaphore.

In other languages

  • The Concurrency library's Getting a result back ↗: in Java and Python a task's failure comes back through its future, as the ExecutionException that get() throws or the exception that result() re-raises. A failed future does nothing to the other tasks, and a call that is already running is not stopped: Python's Future.cancel() returns False for one. Go's cancel does not stop a goroutine either; the tasks here stop because they watch ctx.Done().
  • Python's asyncio.TaskGroup is the closest built-in match: its documentation says that the first time a task in the group fails with an exception other than CancelledError, the remaining tasks are cancelled. The Concurrency library's chapter 06 Async, which covers cancellation, is planned.
  • The Rust library's Spawning a thread ↗: a thread's failure comes back through join, as the Err of a panic. Rust's threads have no built-in cancellation, so the counterpart of ctx is a flag or a channel that every thread checks. (Not machine-checked here.)

Sources