PGH Web

PGH Web

Web Solutions from Pittsburgh

context.Canceled and context.DeadlineExceeded Are Not the Same Error

Jeff Straney·

I spent a week debugging why a job queue was processing requests slower than it should have been. The logs said "context cancelled" for thousands of entries per day. I assumed users were hitting cancel, or the client was timing out. Nothing in my instrumentation suggested a performance problem. The queue was moving. The service was healthy. But the production metrics said we were taking 40% longer on the average request than the staging environment.

Turns out half those "context cancelled" messages were actually deadline exceeded. The upstream timeout was set too aggressively. The requests were timing out, not being cancelled. The code that logged them didn't distinguish between the two, so I was looking at the wrong metric entirely.

ctx.Err() returns one of two sentinel errors: context.Canceled or context.DeadlineExceeded. Most code treats them the same and handles both as "context is done, stop working." That's not wrong, but it loses information you almost always wanted later.

context.Canceled means something upstream decided to stop: the client disconnected, a parent operation decided this branch was no longer needed. context.DeadlineExceeded means time ran out. Your service didn't respond fast enough, or the budget you set with context.WithTimeout expired. They look identical from the outside. They mean completely different things for debugging, metrics, and knowing whether to try again.

Checking for it correctly

The naive check is ctx.Err() == context.Canceled. That works for direct comparison, but errors.Is is the right call:

if errors.Is(err, context.Canceled) {
    // upstream decided to stop
}
if errors.Is(err, context.DeadlineExceeded) {
    // time ran out
}

Errors get wrapped. If a function returns fmt.Errorf("database query: %w", ctx.Err()), a direct equality check will miss it. errors.Is unwraps the chain.

What CancelCause adds

Go 1.20 added context.WithCancelCause and context.CancelCause. When you cancel a context, you can attach an error that says why:

ctx, cancel := context.WithCancelCause(parent)

// somewhere, when you cancel:
cancel(fmt.Errorf("rate limit exceeded for user %s", userID))

// somewhere downstream:
cause := context.Cause(ctx)
// cause is the error you passed to cancel

context.Cause returns whatever was passed to cancel. Nil becomes context.Canceled. An exceeded deadline returns context.DeadlineExceeded. An error you passed returns that error directly.

Before 1.20, the pattern was to thread a separate channel or value through the context to communicate reason. The cause API does that without the extra wiring.

When it matters

The distinction matters at the boundaries: middleware that logs errors, retry logic deciding whether there's any point trying again, metrics that separate timeouts from cancellations.

Inside a handler that's just checking whether to keep going, ctx.Err() != nil is usually fine. But if you're surfacing errors to a caller or writing them to a log, the kind of done matters.

They're not the same thing.