mirror of
https://github.com/coder/coder.git
synced 2026-11-03 05:55:24 +08:00
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.
154 lines
4.5 KiB
Go
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:
|
|
}
|
|
}
|
|
}
|