Files
coder/coderd/x/chatd/chaterror/signals.go
T
Ethan 70f031d793 feat(coderd/chatd): structured chat error classification and retry hardening (#23275)
> **PR Stack**
> 1. #23351 ← `#23282`
> 2. #23282 ← `#23275`
> 3. **#23275** ← `#23349` *(you are here)*
> 4. #23349 ← `main`

---

## Summary

Extracts a structured error classification subsystem for agent chat
(`chatd`) so that retry and error payloads carry machine-readable
metadata — error kind, provider name, HTTP status code, and retryability
— instead of raw error strings.

This is the **backend half** of the error-handling work. The frontend
counterpart is in #23282.

## Changes

### New package: `coderd/chatd/chaterror/`

Canonical error classification — extracts error kind, provider, status
code, and user-facing message from raw provider errors. One source of
truth that drives both retry policy and stream payloads.

- **`kind.go`**: Error kind enum (`rate_limit`, `timeout`, `auth`,
`config`, `overloaded`, `unknown`).
- **`signals.go`**: Signal extraction — parses provider name, HTTP
status code, and retryability from error strings and wrapped types.
- **`classify.go`**: Classification logic — maps extracted signals to an
error kind.
- **`message.go`**: User-facing message templates keyed by kind +
signals.
- **`payload.go`**: Projectors that build `ChatStreamError` and
`ChatStreamRetry` payloads from a classified error.

### Modified

- **`codersdk/chats.go`**: Added `Kind`, `Provider`, `Retryable`,
`StatusCode` fields to `ChatStreamError` and `ChatStreamRetry`.
- **`coderd/chatd/chatretry/`**: Thinned to retry-policy only;
classification logic moved to `chaterror`.
- **`coderd/chatd/chatloop/`**: Added per-attempt first-chunk timeout
(60 s) via `guardedStream` wrapper — produces retryable
`startup_timeout` errors instead of hanging forever.
- **`coderd/chatd/chatd.go`**: Publishes normalized retry/error payloads
via `chaterror` projectors.
2026-03-25 13:47:54 +11:00

105 lines
2.8 KiB
Go

package chaterror
import (
"regexp"
"strconv"
"strings"
)
type providerHint struct {
provider string
patterns []string
}
var (
statusCodePattern = regexp.MustCompile(`(?:status(?:\s+code)?|http)\s*[:=]?\s*(\d{3})`)
standaloneStatusPattern = regexp.MustCompile(`\b(?:401|403|408|429|500|502|503|504|529)\b`)
providerHints = []providerHint{
{provider: "openai-compat", patterns: []string{"openai-compat", "openai compatible"}},
{provider: "azure", patterns: []string{"azure openai", "azure-openai"}},
{provider: "openrouter", patterns: []string{"openrouter"}},
{provider: "bedrock", patterns: []string{"aws bedrock", "bedrock"}},
{provider: "vercel", patterns: []string{"vercel ai gateway", "vercel"}},
{provider: "anthropic", patterns: []string{"anthropic", "claude"}},
{provider: "google", patterns: []string{"google", "gemini", "vertex"}},
{provider: "openai", patterns: []string{"openai"}},
}
overloadedPatterns = []string{"overloaded"}
rateLimitPatterns = []string{"rate limit", "rate_limit", "rate limited", "rate-limited", "too many requests"}
timeoutPatterns = []string{
"timeout",
"timed out",
"service unavailable",
"unavailable",
"connection reset",
"connection refused",
"eof",
"broken pipe",
"bad gateway",
"gateway timeout",
}
authStrongPatterns = []string{
"authentication",
"unauthorized",
"invalid api key",
"invalid_api_key",
"quota",
"billing",
"insufficient_quota",
"payment required",
}
authWeakPatterns = []string{"forbidden"}
configPatterns = []string{
"invalid model",
"model not found",
"model_not_found",
"unsupported model",
"context length exceeded",
"context_exceeded",
"maximum context length",
"malformed config",
"malformed configuration",
}
genericRetryablePatterns = []string{"server error", "internal server error"}
interruptedPatterns = []string{"chat interrupted", "request interrupted", "operation interrupted"}
)
func extractStatusCode(lower string) int {
if matches := statusCodePattern.FindStringSubmatch(lower); len(matches) == 2 {
if code, err := strconv.Atoi(matches[1]); err == nil {
return code
}
return 0
}
for _, loc := range standaloneStatusPattern.FindAllStringIndex(lower, -1) {
// Skip values in host:port text. A later standalone status code in the
// same message may still be valid, so keep scanning.
if loc[0] > 0 && lower[loc[0]-1] == ':' {
continue
}
if code, err := strconv.Atoi(lower[loc[0]:loc[1]]); err == nil {
return code
}
return 0
}
return 0
}
func detectProvider(lower string) string {
for _, hint := range providerHints {
if containsAny(lower, hint.patterns...) {
return hint.provider
}
}
return ""
}
func containsAny(lower string, patterns ...string) bool {
for _, pattern := range patterns {
if strings.Contains(lower, pattern) {
return true
}
}
return false
}