Files
coder/coderd/x/chatd/chatretry/chatretry.go
T
Ethan becc858fa8 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.
2026-06-04 12:52:37 +10:00

154 lines
4.5 KiB
Go

// Package chatretry provides retry logic for transient LLM provider
// errors. It classifies errors as retryable or permanent and uses
// exponential backoff with provider retry hints when available.
package chatretry
import (
"context"
"errors"
"time"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
)
const (
// InitialDelay is the backoff duration for the first retry
// attempt.
InitialDelay = 1 * time.Second
// MaxDelay is the upper bound for the exponential backoff
// duration. Matches the cap used in coder/mux.
MaxDelay = 60 * time.Second
// MaxAttempts is the upper bound on retry attempts before
// giving up. With a 60s max backoff this allows roughly
// 25 minutes of retries, which is reasonable for transient
// LLM provider issues.
MaxAttempts = 25
)
type ClassifiedError = chaterror.ClassifiedError
// IsRetryable reports whether err is retryable. Unlike Retry, it does not
// reclassify bare context.Canceled as a transport reset.
func IsRetryable(err error) bool {
return chaterror.Classify(err).Retryable
}
// Delay returns the backoff duration for the given 0-indexed attempt.
// Uses exponential backoff: min(InitialDelay * 2^attempt, MaxDelay).
// Matches the backoff curve used in coder/mux.
func Delay(attempt int) time.Duration {
d := InitialDelay
for range attempt {
d *= 2
if d >= MaxDelay {
return MaxDelay
}
}
return d
}
// effectiveDelay returns the delay for the given 0-indexed attempt
// while honoring any provider-supplied minimum retry delay.
func effectiveDelay(attempt int, classified ClassifiedError) time.Duration {
delay := Delay(attempt)
if classified.RetryAfter > delay {
return classified.RetryAfter
}
return delay
}
func contextError(ctx context.Context) error {
if cause := context.Cause(ctx); cause != nil {
return cause
}
return ctx.Err()
}
// classifyProviderAttemptError must be called after the caller's context
// has been checked. Provider clients can surface remote stream resets as
// bare context.Canceled, which this converts into a retryable transport reset.
func classifyProviderAttemptError(err error) (ClassifiedError, error) {
classified := chaterror.Classify(err)
if classified.Retryable || classified.StatusCode != 0 || !errors.Is(err, context.Canceled) {
return classified, err
}
wrapped := errors.Join(chaterror.ErrProviderTransportReset, err)
reclassified := chaterror.Classify(wrapped)
if !reclassified.Retryable {
return classified, err
}
return reclassified, wrapped
}
// RetryFn is the function to retry. It receives a context and returns
// an error. The context may be a child of the original with adjusted
// deadlines for individual attempts.
type RetryFn func(ctx context.Context) error
// OnRetryFn is called before each retry attempt with the attempt
// number (1-indexed), the raw error that triggered the retry, the
// normalized error payload, and the delay before the next attempt.
type OnRetryFn func(attempt int, err error, classified ClassifiedError, delay time.Duration)
// Retry calls fn repeatedly until it succeeds, returns a
// non-retryable error, ctx is canceled, or MaxAttempts is reached.
// Retries use exponential backoff capped at MaxDelay, unless the
// normalized error includes a longer provider Retry-After hint.
//
// When fn returns bare context.Canceled while ctx is still alive, Retry
// treats it as a provider transport reset and retries it.
//
// The onRetry callback (if non-nil) is called before each retry
// attempt, giving the caller a chance to reset state, log, or
// publish status events.
func Retry(ctx context.Context, fn RetryFn, onRetry OnRetryFn) error {
var attempt int
for {
if ctxErr := contextError(ctx); ctxErr != nil {
return ctxErr
}
err := fn(ctx)
if err == nil {
return nil
}
// fn runs with ctx. If it canceled the caller's context, that cause
// wins over the provider error returned from fn.
if ctxErr := contextError(ctx); ctxErr != nil {
return ctxErr
}
classified, err := classifyProviderAttemptError(err)
if !classified.Retryable {
return chaterror.WithClassification(err, classified)
}
attempt++
if attempt >= MaxAttempts {
return chaterror.WithClassification(
xerrors.Errorf("max retry attempts (%d) exceeded: %w", MaxAttempts, err),
classified,
)
}
delay := effectiveDelay(attempt-1, classified)
if onRetry != nil {
onRetry(attempt, err, classified, delay)
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return contextError(ctx)
case <-timer.C:
}
}
}