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!
Enjoy this post? Don't be a stranger!
Follow me on Twitter at @_josephwoodward and say Hi! I love to learn in the open, meet others in the community and talk Go, software engineering and distributed systems related topics.