mirror of
https://github.com/coder/coder.git
synced 2026-11-09 03:43:54 +08:00
> **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.
105 lines
2.8 KiB
Go
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
|
|
}
|