Skip to content

Cancel with a cause

Level: 201 · anyone who has logged context canceled and then had to guess why

One line: ctx.Err() can only say context canceled or context deadline exceeded; context.WithCancelCause records the error that caused the cancel, WithTimeoutCause and WithDeadlineCause name what a deadline meant, and context.Cause reads the reason back from the context or anything derived from it.

The Err method of a Context has two non-nil answers, and neither says what went wrong. When one step of a request fails and cancels the others, every other step sees the same context canceled, and the failure that started it is wherever the failing step happened to put it. The program runs three steps of one order on three goroutines; the stock check fails and cancels the other two, with its error as the cause.

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

1. three steps of one order; the stock check fails and cancels the rest
   stock    returned:  warehouse: out of stock
   payment  returned:  context canceled
   shipping returned:  context canceled
   order.Err():          context canceled
   context.Cause(order): warehouse: out of stock
2. Cause through a child, and Cause with no cause given
   label.Err():          context canceled
   context.Cause(label): warehouse: out of stock
   errors.Is(context.Cause(label), errOutOfStock): true
   not canceled yet:        Cause = <nil>
   plain WithCancel:        Cause = context canceled
   WithCancelCause, nil:    Cause = context canceled
3. WithTimeoutCause: the cause is recorded only if the clock runs out
   the clock ran out:    Err = context deadline exceeded  Cause = supplier: no quote within 100ms
   cancel() came first:  Err = context canceled           Cause = context canceled

Reading the output

1. payment and shipping were stopped, and context canceled is all they could report; order.Err() says the same. context.Cause(order) returned the stock check's error. Each of the three goroutines called cancelOrder with the error it returned, including the two that returned context canceled, and only the first call counted: the documentation for CancelCauseFunc says it does not set the cause of a context that is already canceled. So every goroutine can report whatever error it got, and the one that started the cancel is the one that stays.

2. label was derived from order with a plain WithCancel before anything failed, and context.Cause(label) still returns the stock error, because Cause returns the cause set by the first cancellation of the context or of one of its parents. errors.Is works on it, so code can branch on why it was stopped. With no cause recorded, Cause falls back to Err: nil before the cancel, context canceled after a plain one, and context canceled again when a CancelCauseFunc is given nil.

3. WithTimeoutCause sets its cause only when the clock runs out. Its CancelFunc takes no argument and, as the documentation says, does not set the cause, so a context cancelled before its deadline reads as a plain context canceled.

A parent and a child can each be cancelled with a cause of their own. The CancelCauseFunc documentation spells out the order: if the parent is cancelled first, both report the parent's cause; if the child is cancelled first, it keeps its own cause and the parent later gets its own.

cancel_with_a_cause_go.go

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

// ctx.Err() says only that a context was canceled, or that its deadline
// passed. WithCancelCause records why, and context.Cause reads the reason
// back, from that context or from any context derived from it.
//
//  go build cancel_with_a_cause_go.go && ./cancel_with_a_cause_go
package main

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

var (
    errOutOfStock   = errors.New("warehouse: out of stock")
    errSupplierSlow = errors.New("supplier: no quote within 100ms")
)

// waitForCancel is a step with nothing to do until it is told to stop.
func waitForCancel(ctx context.Context) error {
    <-ctx.Done()
    return ctx.Err()
}

func main() {
    bg := context.Background()

    fmt.Println("1. three steps of one order; the stock check fails and cancels the rest")
    order, cancelOrder := context.WithCancelCause(bg)
    defer cancelOrder(nil)
    label, cancelLabel := context.WithCancel(order) // made before anything fails
    defer cancelLabel()

    steps := []struct {
        name string
        run  func(context.Context) error
    }{
        {"stock", func(context.Context) error { return errOutOfStock }},
        {"payment", waitForCancel},
        {"shipping", waitForCancel},
    }
    returned := make([]error, len(steps))
    var wg sync.WaitGroup
    for i, s := range steps {
        wg.Go(func() {
            err := s.run(order)
            returned[i] = err
            cancelOrder(err) // every step reports its error; only the first one sticks
        })
    }
    wg.Wait()
    for i, s := range steps {
        fmt.Printf("   %-8s returned:  %v\n", s.name, returned[i])
    }
    fmt.Println("   order.Err():         ", order.Err())
    fmt.Println("   context.Cause(order):", context.Cause(order))

    fmt.Println("2. Cause through a child, and Cause with no cause given")
    fmt.Println("   label.Err():         ", label.Err())
    fmt.Println("   context.Cause(label):", context.Cause(label))
    fmt.Println("   errors.Is(context.Cause(label), errOutOfStock):", errors.Is(context.Cause(label), errOutOfStock))
    plain, cancelPlain := context.WithCancel(bg)
    fmt.Println("   not canceled yet:        Cause =", context.Cause(plain))
    cancelPlain()
    fmt.Println("   plain WithCancel:        Cause =", context.Cause(plain))
    withNil, cancelWithNil := context.WithCancelCause(bg)
    cancelWithNil(nil)
    fmt.Println("   WithCancelCause, nil:    Cause =", context.Cause(withNil))

    fmt.Println("3. WithTimeoutCause: the cause is recorded only if the clock runs out")
    ctx, cancel := context.WithTimeoutCause(bg, 100*time.Millisecond, errSupplierSlow)
    <-ctx.Done()
    fmt.Printf("   %-21s Err = %-26v Cause = %v\n", "the clock ran out:", ctx.Err(), context.Cause(ctx))
    cancel()
    ctx, cancel = context.WithTimeoutCause(bg, 100*time.Millisecond, errSupplierSlow)
    cancel()
    fmt.Printf("   %-21s Err = %-26v Cause = %v\n", "cancel() came first:", ctx.Err(), context.Cause(ctx))
}

Which Go added what

Added in What
Go 1.20 ↗ WithCancelCause, CancelCauseFunc and Cause
Go 1.21 ↗ WithDeadlineCause and WithTimeoutCause

The standard library uses causes itself: since Go 1.26 ↗, signal.NotifyContext cancels its context with a cause saying which signal arrived. (This library runs Go 1.25, so that is not machine-checked here.)

What to do

  • When a goroutine stops the others because of an error, cancel with WithCancelCause and pass that error. Every goroutine can pass whatever it got; the first cause is the one kept.
  • Keep returning ctx.Err() from the <-ctx.Done() case, so callers that test errors.Is(err, context.Canceled) go on working, and report context.Cause(ctx) where the failure is logged or returned to a user.
  • Name a timeout with WithTimeoutCause when one request has several that could fire, so the log says which.
  • First error cancels the rest is the pattern this was made for.

In other languages

  • The Concurrency library's Getting a result back ↗ follows a failure the other way: from a thread back to whoever joins it, as Rust's Err holding the panic payload, a Java ExecutionException with the exception as its cause, or an exception Python re-raises from result(). A context's cause travels outward instead, from the goroutine that failed to every goroutine sharing the context.
  • The Rust library's Spawning a thread ↗: join returns the panic payload to the one thread that joins. The standard library has no cancellation to attach a reason to, so a reason for stopping is whatever the flag or channel that stops the thread carries.
  • The Concurrency library's planned lesson "A failure nobody is waiting for", in chapter 01, and its planned chapter 06, Async, where cancelling a task is part of the language, have no page yet.

Sources