Files
coder/coderd/chatd/chatretry/chatretry.go
T
Kyle Carberry 2bdacae5f5 feat(chatd): add LLM stream retry with exponential backoff (#22418)
## Summary

Adds automatic retry with exponential backoff for transient LLM errors
during chat streaming and title generation. Inspired by
[coder/mux](https://github.com/coder/mux)'s retry mechanism.

## Key Behaviors

- **Infinite retries** with exponential backoff: 1s → 2s → 4s → ... →
60s cap
- **Deterministic delays** (no jitter)
- **Error classification**: retryable (429, 5xx, overloaded, rate limit,
network errors) vs non-retryable (auth, quota, context exceeded, model
not found, canceled)
- **Retry status published to SSE stream** so frontend can show
"Retrying in Xs..." UI
- **Title generation** retries silently (best-effort, nil onRetry
callback)

## New Package: `coderd/chatd/chatretry/`

| File | Purpose |
|------|---------|
| `classify.go` | `IsRetryable(err)` and `StatusCodeRetryable(code)` |
| `backoff.go` | `Delay(attempt)` — exponential doubling with 60s cap |
| `retry.go` | `Retry(ctx, fn, onRetry)` — infinite loop with
context-aware timer |

## Test Helpers: `coderd/chatd/chattest/errors.go`

Anthropic and OpenAI error response builders for use in chattest
providers:
- `AnthropicErrorResponse()`, `AnthropicOverloadedResponse()`,
`AnthropicRateLimitResponse()`
- `OpenAIErrorResponse()`, `OpenAIRateLimitResponse()`,
`OpenAIServerErrorResponse()`

## SDK Changes: `codersdk/chats.go`

- New `ChatStreamEventType: "retry"`
- New `ChatStreamRetry` struct with `Attempt`, `DelayMs`, `Error`,
`RetryingAt` fields
- TypeScript types auto-generated

## Changed Files

- `coderd/chatd/chatloop/chatloop.go` — wraps `agent.Stream()` in
`chatretry.Retry()`
- `coderd/chatd/chatd.go` — publishes retry events to SSE stream with
logging
- `coderd/chatd/title.go` — wraps `model.Generate()` in silent retry
- `coderd/chatd/chattest/anthropic.go` / `openai.go` — error injection
support

## Tests

42 tests covering classification (33), backoff (9), and retry scenarios
(8).
2026-02-27 18:34:33 -05:00

176 lines
4.1 KiB
Go

// Package chatretry provides retry logic for transient LLM provider
// errors. It classifies errors as retryable or permanent and
// implements exponential backoff matching the behavior of coder/mux.
package chatretry
import (
"context"
"errors"
"strings"
"time"
)
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
)
// nonRetryablePatterns are substrings that indicate a permanent error
// which should not be retried. These are checked first so that
// ambiguous messages (e.g. "bad request: rate limit") are correctly
// classified as non-retryable.
var nonRetryablePatterns = []string{
"context canceled",
"context deadline exceeded",
"authentication",
"unauthorized",
"forbidden",
"invalid api key",
"invalid_api_key",
"invalid model",
"model not found",
"model_not_found",
"context length exceeded",
"context_exceeded",
"maximum context length",
"quota",
"billing",
}
// retryablePatterns are substrings that indicate a transient error
// worth retrying.
var retryablePatterns = []string{
"overloaded",
"rate limit",
"rate_limit",
"too many requests",
"server error",
"status 500",
"status 502",
"status 503",
"status 529",
"connection reset",
"connection refused",
"eof",
"broken pipe",
"timeout",
"unavailable",
"service unavailable",
}
// IsRetryable determines whether an error from an LLM provider is
// transient and worth retrying. It inspects the error message and
// any wrapped HTTP status codes for known retryable patterns.
func IsRetryable(err error) bool {
if err == nil {
return false
}
// context.Canceled is always non-retryable regardless of
// wrapping.
if errors.Is(err, context.Canceled) {
return false
}
lower := strings.ToLower(err.Error())
// Check non-retryable patterns first so they take precedence.
for _, p := range nonRetryablePatterns {
if strings.Contains(lower, p) {
return false
}
}
for _, p := range retryablePatterns {
if strings.Contains(lower, p) {
return true
}
}
return false
}
// StatusCodeRetryable returns true for HTTP status codes that
// indicate a transient failure worth retrying.
func StatusCodeRetryable(code int) bool {
switch code {
case 429, 500, 502, 503, 529:
return true
default:
return false
}
}
// 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
}
// 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 error that triggered the retry, and the
// delay before the next attempt.
type OnRetryFn func(attempt int, err error, delay time.Duration)
// Retry calls fn repeatedly until it succeeds, returns a
// non-retryable error, or ctx is canceled. There is no max attempt
// limit — retries continue indefinitely with exponential backoff
// (capped at 60s), matching the behavior of coder/mux.
//
// 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 {
err := fn(ctx)
if err == nil {
return nil
}
if !IsRetryable(err) {
return err
}
// If the caller's context is already done, return the
// context error so cancellation propagates cleanly.
if ctx.Err() != nil {
return ctx.Err()
}
delay := Delay(attempt)
if onRetry != nil {
onRetry(attempt+1, err, delay)
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
attempt++
}
}