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
errgroupitself wheregolang.org/xis allowed. It saves writing thegrouptype, and itsSetLimitadds 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
ExecutionExceptionthatget()throws or the exception thatresult()re-raises. A failed future does nothing to the other tasks, and a call that is already running is not stopped: Python'sFuture.cancel()↗ returnsFalsefor one. Go's cancel does not stop a goroutine either; the tasks here stop because they watchctx.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 thanCancelledError, 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 theErrof a panic. Rust's threads have no built-in cancellation, so the counterpart ofctxis a flag or a channel that every thread checks. (Not machine-checked here.)
Sources¶
context.WithCancelCause↗,context.Cause↗ andcontext.CancelCauseFunc↗, all added in Go 1.20.sync.WaitGroup.Go↗, added in Go 1.25, andsync.Once↗.golang.org/x/sync/errgroup↗:WithContext,Group.Go,Group.WaitandGroup.SetLimit.- Jon Bodner, Learning Go, 2nd ed. (O'Reilly, 2024), chapter 12, "Concurrency in Go": the aside "golang.org/x and errgroup.Group".
- Burak Serdar, Effective Concurrency in Go (Packt, 2023), chapter 6, "Error Handling".
- Katherine Cox-Buday, Concurrency in Go (O'Reilly, 2017), chapter 5, "Concurrency at Scale": "Error Propagation".