fix(coderd/x/chatd): retry provider stream cancellations (#26010)

Closes CODAGT-541.

## Problem

An Agents chat stream could die with a terminal `context cancelled`
error and surface to the user as a permanent chat failure, even when no
context in our process had actually been canceled. The cancellation was
a provider-returned error value (HTTP/2 RST_STREAM mid-body surfacing as
`context.Canceled` from Go's net/http2), not a real caller cancel.

The chain that produced the bug:

- fantasy passed the provider's `context.Canceled` through unchanged.
- `chaterror.Classify` short-circuited any `errors.Is(err,
context.Canceled)` (or `"context canceled"` text) as terminal generic,
before checking HTTP status codes or other retry signals.
- `chatretry.Retry` did not retry.
- The frontend rendered `type:"error"` and the chat was dead.

The same short-circuit also masked retryable 5xx responses whose
underlying transport error happened to wrap `context.Canceled`.

## Approach

`context.Canceled` has no inherent intent. The same error value can mean
a user pressing Stop, a server shutdown, the silence guard firing, or a
provider-side stream reset. The only layer that can disambiguate is the
one holding both the returned error and the caller context. That is
`chatretry`.

This PR centralizes the policy there and keeps `chaterror` context-free.

## Changes

`coderd/x/chatd/chaterror/classify.go`

- Add `ErrProviderTransportReset` sentinel to explicitly mark
provider-side stream cancellations.
- Remove the broad `context.Canceled` / `"context canceled"`
short-circuit so status codes and other retry signals can win.
- Classify `ErrProviderTransportReset` (with no status code) as a
retryable timeout.
- Keep a fallback that classifies bare `context.Canceled` as
terminal-generic when no other signal is present, so legitimate caller
cancels still terminate cleanly.

`coderd/x/chatd/chatretry/chatretry.go`

- Add `contextError(ctx)` that returns `context.Cause(ctx)` when set,
falling back to `ctx.Err()`, so caller-owned cancel causes
(`ErrInterrupted`, `errStreamSilenceTimeout`, server shutdown sentinels)
propagate cleanly out of the retry loop.
- Add `classifyProviderAttemptError(err)` that wraps a bare
`context.Canceled` in `ErrProviderTransportReset` and reclassifies.
Errors that already classify as retryable or carry a status code are
left alone.
- Restructure `Retry` so the policy is explicit and readable: check
caller cancellation before attempting, run the attempt, check caller
cancellation again before normalizing the provider error, then classify
and retry.

## End-to-end behavior

- Provider returns `context.Canceled` while caller context is healthy:
classified as a retryable timeout, retried, the user sees a brief
`type:"retry"` event and the chat continues.
- User presses Stop: `contextError(ctx)` returns `ErrInterrupted`. Retry
stops. `chatloop` flushes partial content and persists.
- Stream-silence guard fires: `attemptCtx` is canceled with
`errStreamSilenceTimeout`, `guardedStream` produces a classified
retryable error, retry proceeds normally on the still-alive parent.
- Server shutdown: parent context's cause propagates out, retry stops.
This commit is contained in:
Ethan
2026-06-04 12:52:37 +10:00
committed by GitHub
parent a67f53870f
commit becc858fa8
6 changed files with 332 additions and 35 deletions
+24 -5
View File
@@ -7,10 +7,15 @@ import (
"time"
"golang.org/x/net/http2"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
)
// ErrProviderTransportReset identifies provider stream cancellations that
// occur while the caller-owned chat context is still alive.
var ErrProviderTransportReset = xerrors.New("provider transport reset")
// ClassifiedError is the normalized, user-facing view of an
// underlying provider or runtime error.
type ClassifiedError struct {
@@ -147,9 +152,10 @@ func Classify(err error) ClassifiedError {
statusCode = extractStatusCode(lower)
}
provider := detectProvider(lower)
canceled := errors.Is(err, context.Canceled) || strings.Contains(lower, "context canceled")
canceled := errors.Is(err, context.Canceled)
providerTransportReset := errors.Is(err, ErrProviderTransportReset)
interrupted := containsAny(lower, interruptedPatterns...)
if canceled || interrupted {
if interrupted {
return normalizeClassification(ClassifiedError{
Message: "The request was canceled before it completed.",
Detail: structured.detail,
@@ -209,9 +215,11 @@ func Classify(err error) ClassifiedError {
// over broader string fallbacks so protocol bugs do not retry.
timeoutPatternMatch = false
}
timeoutMatch := deadline || statusCode == 408 || statusCode == 502 ||
statusCode == 503 || statusCode == 504 ||
retryableHTTP2StreamReset || timeoutPatternMatch
providerTransportResetMatch := providerTransportReset && statusCode == 0
timeoutMatch := providerTransportResetMatch || deadline ||
statusCode == 408 || statusCode == 502 || statusCode == 503 ||
statusCode == 504 || retryableHTTP2StreamReset ||
timeoutPatternMatch
genericRetryableMatch := statusCode == 500 || containsAny(lower, genericRetryablePatterns...)
// Config signals should beat ambiguous wrapper signals so
@@ -289,6 +297,17 @@ func Classify(err error) ClassifiedError {
})
}
if canceled {
return normalizeClassification(ClassifiedError{
Message: "The request was canceled before it completed.",
Detail: structured.detail,
Kind: codersdk.ChatErrorKindGeneric,
Provider: provider,
StatusCode: statusCode,
RetryAfter: structured.retryAfter,
})
}
return normalizeClassification(ClassifiedError{
Detail: structured.detail,
Kind: codersdk.ChatErrorKindGeneric,
+52
View File
@@ -2,6 +2,7 @@ package chaterror_test
import (
"context"
"errors"
"fmt"
"io"
"net/http"
@@ -219,6 +220,57 @@ func TestClassify(t *testing.T) {
StatusCode: 0,
},
},
{
name: "ProviderTransportResetIsRetryable",
err: errors.Join(chaterror.ErrProviderTransportReset, context.Canceled),
want: chaterror.ClassifiedError{
Message: "The AI provider is temporarily unavailable.",
Kind: codersdk.ChatErrorKindTimeout,
Provider: "",
Retryable: true,
StatusCode: 0,
},
},
{
name: "BareContextCanceledStaysNonRetryable",
err: context.Canceled,
want: chaterror.ClassifiedError{
Message: "The request was canceled before it completed.",
Kind: codersdk.ChatErrorKindGeneric,
Provider: "",
Retryable: false,
StatusCode: 0,
},
},
{
name: "Status500ContextCanceledClassifiesAsRetryable",
err: xerrors.Errorf("received status 500 from upstream: %w", context.Canceled),
want: chaterror.ClassifiedError{
Message: "The AI provider returned an unexpected error.",
Kind: codersdk.ChatErrorKindGeneric,
Provider: "",
Retryable: true,
StatusCode: http.StatusInternalServerError,
},
},
{
name: "ProviderStatus500ContextCanceledClassifiesAsRetryable",
err: xerrors.Errorf("provider stream closed: %w", errors.Join(
context.Canceled,
&fantasy.ProviderError{
Message: "context canceled",
StatusCode: http.StatusInternalServerError,
},
)),
want: chaterror.ClassifiedError{
Message: "The AI provider returned an unexpected error.",
Detail: "context canceled",
Kind: codersdk.ChatErrorKindGeneric,
Provider: "",
Retryable: true,
StatusCode: http.StatusInternalServerError,
},
},
// The next cases model the error that fantasy produces
// when aibridge's disabledProviderHandler returns a 503
// plain-text sentinel. Fantasy sets Title from the HTTP