Better Context Canceled Errors

Published Jan 23, 2026 ·

Situation

When a service starts failing at scale, we usually go straight to dashboards and logs. But it happens that the logs are flooded with one unhelpful line: context canceled errors. It is a frustratingly silent failure and are very common. Most of the time it tells you that the work has stopped, but not the main question is why it stopped.

In modern microservice environments, it is important to understand the root cause quickly: a canceled request might be a harmless client disconnect, or it might be a symptom of a deeper incident. Without extra context you must rely on more information from traces, metrics, or other logs to reconstruct what happened.

The request can be canceled for many reasons: the client disconnected, a timeout was reached, or the server decided to cancel it for some internal reason. The cancellation is not the problem (actually it is great mechanism), the main problem is lack of the information about the reason behind it, when you need to debug it.

Adding causes to cancellations

The Go 1.20, introduced a way to add cause of cancellation to context using context.WithCancelCause.

The behavior is the same as context.WithCancel, but with an additional ability in cancel function to provide an error cause. This can be done following way:

ctx, cancel := context.WithCancelCause(parentCtx)
// then we can cancel with a specific cause
// Some operation that may lead to cancellation
if someCondition {
    cancel(errors.New("specific reason for cancellation"))
}

This function allows us to create a cancellable context that can carry an error cause along with the cancellation signal. When canceling the context, we can provide a specific error that describes why the cancellation occurred.

We can go further and provide more structured information about the cancellation. Imagine you have the service interact with multiple external systems, and each of them can fail for different reasons. We can define a custom error type to represent these failures:

type CloudError struct {
	Service string
	Code    int
	Reason  string
}
func (e *CloudError) Error() string {
	return fmt.Sprintf("[%s] Error %d: %s", e.Service, e.Code, e.Reason)
}

Now canceling happens with structured error for specific scenario, like this:

serviceUnavalilable := &CloudError{
    Service: "S3",
    Code:    503,
    Reason:  "service unavailable",
}

if someCondition {
    cancel(serviceUnavalilable)
}

The context.WithCancelCause lets you attach an error cause to a cancellation. The canonical cancellation signal remains ctx.Err() (which is context.Canceled in this example), while the actual ”why” explanation is available via context.Cause(ctx).

Because the cause is stored on the context (not inside ctx.Err()), you’ll only see it if you log it explicitly in middleware/interceptors. For example as an example simple fmt.Printf log:

fmt.Printf("%v: %v\n", ctx.Err(), context.Cause(ctx))

Which will look like this when we check the error in logs:

context canceled: [S3] Error 503: service unavailable

Good. Now we have information and ability to provide it when context is canceled.

Note: Remember that context.Cause(ctx) returns nil if the context hasn’t been canceled yet, and if no cause was given it will return ctx.Err(). For example, the above printf will print context canceled: context canceled if no specific cause was provided.

Middleware example

As said before, the cause can be logged in middleware/interceptors. Here is a minimal example of HTTP middleware that logs the cancellation cause:

func logCancelCause(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        next.ServeHTTP(w, r)

        ctx := r.Context()
        if err := ctx.Err(); err != nil {
            cause := context.Cause(ctx)
            if cause != nil && cause != err {
                log.Printf("request canceled: err=%v cause=%v", err, cause)
            } else {
                log.Printf("request canceled: err=%v", err)
            }
        }
    })
}

So you will not forget to log the cause of cancellation when it happens.

Handling the cause

Not all cancellations deserve an alert. Some of them are expected, like timeouts or client disconnects. Once we have extra information we can branch our handling logic based on the cause.

The release of Go 1.26 (planned for Feb 2026 at time of writing), adds generic helper errors.AsType to extract typed errors from error chains in more safe way than errors.As. In our case we will use it to extract CloudError from the cause.

First read the cause:

cause := context.Cause(ctx)

Then check if it is your structured error type:

if cloudErr, ok := errors.AsType[*CloudError](cause); ok {
    if cloudErr == serviceUnavalilable {
        // Handle this specific cancellation
    }
} else {
    // Generic or unexpected cancellation scenario
}

Important: The matching still happens in runtime. If the cause is not of the expected type, errors.AsType will return ok == false without runtime panic. In this way type safety is ensured here by the API.

At this point we can react to the cause as it makes sense for our application. And retry strategies or fallbacks can be implemented based on the specific error type.

Previous approaches

Runtime reflection with errors.As

Before Go 1.26, the usual tool was errors.As. It does it job, but also requiers a target variable and can panic at runtime if the target is not a correctly-typed non-nil pointer.

var cloudErr *CloudError
if errors.As(cause, &cloudErr) {
    // ...
}

Compared to errors.AsType, it’s more verbose and easier to misuse mechanically, especially if you have many different error types to check for and it is repeated across the codebase.

In older codebases you might still find direct string matching. It’s fragile and breaks easily when error messages changes.

if strings.Contains(cause.Error(), "service unavailable") {
    // Handle this specific cancellation
}

Both errors.As and errors.AsType are better choices to this approach, because they rely on type information rather than on content.

Conclusion

The context.WithCancelCause and structured errors give you a way to record why a process was canceled without changing the error. With checks using errors.AsType you can react to specific cancellation reasons in a type-safe way. Many cancellations, where the cause is recorded, can explain themselves and become useful signals for your observability systems. A plain context canceled without recorded cause will stand out and is a hint that you hitting unhandled path and need to investigate further.

You can find a runnable demo here: Go Playground.

Sources

Continue Reading