Providing context to cancellations in Go 1.20 with the new context WithCause API

Posted on Wednesday, 4th January 2023

Having been experimenting with some of the changes making their way into Go 1.20, there was one change I felt was worthy of its own post. This change is the addition of APIs for writing and reading cancellation causes in the standard library’s context package. Let’s have a look…

The Problem

Prior to Go 1.20, out of the box options for understanding the reason for a context’s cancellation were limited to two exported errors from the context package, context.DeadlineExceeded or context.Canceled. Though rather simplistic, below is a typical way these errors are commonly checked:

func main() {
    // Pass a context with a timeout to tell a blocking function that it
    // should abandon its work after the timeout elapses.
    timeoutDuration := 3 * time.Second
    ctx, cancel := context.WithTimeout(context.Background(), timeoutDuration)
    defer cancel()

    // block until context is timed out
    <-ctx.Done()

    switch ctx.Err() {
        case context.DeadlineExceeded:
            fmt.Println("context timeout exceeded")
        case context.Canceled:
            fmt.Println("context cancelled by force")
    }
   
   // output:
   // context timeout exceeded
}

For a lot of cases this solution is sufficient, however there are occasions where you wish to provide more “context” as to why the context was canceled, in the form of custom error so developers can introspect it via the errors.Is/As functions to determine, for instance, whether the error is retryable or not.

Similarly when a context is explicitly cancelled by invoking the context.CancelFunc() function (assigned to the cancel variable in the above demonstration) there’s no way to communicate whether this was due to an error or not.

This is the problem the following two accepted proposals aim to address with the addition of a new WithCause API to Go’s context package available in Go 1.20. First by adding context.WithCancelCause and a follow-on proposal to add WithDeadlineCause and WithTimeoutCause to the context package.

Let’s take a look.

Providing context to your context cancellation

Explicit cancellation via WithCancelCause

As demonstrated below, using this new WithCancelCause API available in Go 1.20 allows us to pass a custom error type when cancelling the context. This is then stored on the context for retrieval:

package main

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

var ErrTemporarilyUnavailable = fmt.Errorf("service temporarily unavailable")

func main() {
    ctx, cancel := context.WithCancelCause(context.Background())

    // operation failed, let's notify the caller by cancelling the context
    cancel(ErrTemporarilyUnavailable)

    switch ctx.Err() {
    case context.Canceled:
        fmt.Println("context cancelled by force")
    }

    // get the cause of cancellation, in this case the ErrTemporarilyUnavailable error
    err := context.Cause(ctx)

    if errors.Is(err, ErrTemporarilyUnavailable) {
        fmt.Printf("cancallation reason: %s", err)
    }
    
    // cancallation reason: service temporarily unavailable
}

As an API author we’re now able to provide our consumers with more information as to why the context was canceled, for instance, communicating whether the operation is safe to retry or not.

Timed cancellation with WithDeadlineCause and WithTimeoutCause

In addition to forced cancellation demonstrated above, Go 1.20 will also include the same capabilities with the timed cancellations via WithDeadlineCause and WithTimeoutCause. The main difference being that the timed cause functions, WithDeadlineCause and WithTimeoutCause require you to specify the cause of the cancellation when creating the context, as opposed to when invoking the CancelFunc returned. Let’s take a look:

...

var ErrFailure = fmt.Errorf("request took too long")

func main() {
    timeout := time.Duration(2 * time.Second)
    ctx, _ := context.WithTimeoutCause(context.Background(), timeout, ErrFailure)

    // wait for the context to timeout
    <-ctx.Done()

    switch ctx.Err() {
    case context.DeadlineExceeded:
        fmt.Printf("operation could not complete: %s", context.Cause(ctx))
    }
        
    // operation could not complete: request took too long 
}

And as you’d expect, given the similarities between WithDeadline and WithTimeout the same rules apply to the WithDeadlineCause in that it takes the cancellation cause when creating the context:

...

var ErrFailure = fmt.Errorf("request took too long")

func main() {
    timeout := time.Now().Add(time.Duration(2 * time.Second))
    ctx, _ := context.WithDeadlineCause(context.Background(), timeout, ErrFailure)

    // wait for the context to timeout
    <-ctx.Done()

    switch ctx.Err() {
    case context.DeadlineExceeded:
        fmt.Printf("operation could not complete: %s", context.Cause(ctx))
    }
        
    // operation could not complete: request took too long 
}

Wrapping up

Hopefully you found this post enlightening and the trivial examples adequately demonstrated the challenges the new WithCause changes to the context package coming in Go 1.20 aim to solve. If you have the time I’d highly recommend looking over the original issue that led to this proposal and change, and of course the discussions around the proposal itself.

See you next time!