Go Context Cause: Stop Debugging Blind context canceled Errors

Go 1.20 introduced context.WithCancelCause and context.Cause, transforming cancellation from a generic signal into a traceable failure event.

Go Context Cause: Stop Debugging Blind context canceled Errors

The Real Problem With context canceled

ctx.Err() only tells you what class of failure happened:

  • context.Canceled
  • context.DeadlineExceeded

That’s enough to branch logic, but useless when you’re trying to answer:

  • Which dependency failed?
  • Why did we cancel this request?
  • Where did the timeout originate?

In production, this turns into a wall of identical context canceled logs with no way to distinguish user aborts, upstream failures, or internal guardrails.

Go 1.20+ fixes this by separating cancellation class from cancellation cause.

info

Two dimensions of failure

`ctx.Err()` = broad category (canceled vs deadline exceeded). `context.Cause(ctx)` = concrete reason (domain error, wrapped error, etc.).

What Go 1.20+ Changed

New APIs:

  • Go 1.20
  • context.WithCancelCause(parent)(ctx, cancel func(error))
  • context.Cause(ctx)error
  • Go 1.21
  • context.WithTimeoutCause(parent, d, cause)
  • context.WithDeadlineCause(parent, t, cause)

This turns cancellation into a traceable event:

  • You still use ctx.Done() and ctx.Err() for control flow.
  • You additionally use context.Cause(ctx) to understand why the context ended.

Pattern 1: Use WithCancelCause For Explicit Failure Paths

At request or job boundaries, wrap work in a single WithCancelCause and set meaningful domain errors at the closest failure point.

Example: HTTP handler orchestration

Use one CancelCauseFunc and attach causes where you decide to abort:

handler.go
var (
    ErrUpstreamUnavailable = errors.New("upstream unavailable")
    ErrBadRequest         = errors.New("bad request")
)

func handler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithCancelCause(r.Context())
    defer cancel(nil) // nil on normal completion

    // Validate input
    if err := validate(r); err != nil {
        cancel(fmt.Errorf("validate request: %w", ErrBadRequest))
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }

    // Call upstream service
    resp, err := callUpstream(ctx, r)
    if err != nil {
        cancel(fmt.Errorf("call upstream: %w", ErrUpstreamUnavailable))
        http.Error(w, "upstream error", http.StatusBadGateway)
        return
    }

    // Happy path: we finished without needing to set a cause
    // defer cancel(nil) runs here
    _ = resp
}

func logRequestEnd(ctx context.Context, logger *zap.Logger) {
    errClass := ctx.Err()          // context.Canceled or context.DeadlineExceeded or nil
    cause := context.Cause(ctx)    // domain error or nil

    logger.Info("request done",
        zap.Error(errClass),
        zap.NamedError("cause", cause),
    )
}
auto_awesome

One cancel function per orchestration scope

Create a single `WithCancelCause` at the orchestration boundary (handler, worker, job) and set causes at the highest level that understands *why* you’re aborting.

Pattern 2: Understand the WithTimeoutCause Trap

WithTimeoutCause looks like a drop-in replacement for WithTimeout, but it is not a general-purpose WithCancelCause with a timer.

Signature: