refactor: move chat error kinds into codersdk (#24955)

Moves the chat error kind taxonomy from `coderd/x/chatd/chaterror` into
`codersdk.ChatErrorKind` and types `ChatError.Kind` /
`ChatStreamRetry.Kind` so generated TypeScript exposes an SDK-owned
union, including `usage_limit`. Backend chat classification now
references the SDK constants directly while preserving the existing JSON
string values.

Keeps chat usage-limit admission failures on their existing 409 response
shape. The frontend maps structured usage-limit responses to the
SDK-owned `usage_limit` kind, uses generated `TypesGen.ChatErrorKind`
directly, and removes the local string union and alias.
This commit is contained in:
Ethan
2026-05-06 11:57:48 +10:00
committed by GitHub
parent dc14ab6b97
commit 46a60e6d5d
29 changed files with 305 additions and 233 deletions
+3 -4
View File
@@ -28,7 +28,6 @@ import (
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisionersdk/proto"
@@ -1619,16 +1618,16 @@ func decodeChatLastError(raw pqtype.NullRawMessage) *codersdk.ChatError {
if err := json.Unmarshal(raw.RawMessage, &payload); err != nil {
return &codersdk.ChatError{
Message: fallbackChatLastErrorMessage,
Kind: chaterror.KindGeneric,
Kind: codersdk.ChatErrorKindGeneric,
}
}
payload.Message = strings.TrimSpace(payload.Message)
payload.Detail = strings.TrimSpace(payload.Detail)
payload.Kind = strings.TrimSpace(payload.Kind)
payload.Kind = codersdk.ChatErrorKind(strings.TrimSpace(string(payload.Kind)))
payload.Provider = strings.TrimSpace(payload.Provider)
if payload.Kind == "" {
payload.Kind = chaterror.KindGeneric
payload.Kind = codersdk.ChatErrorKindGeneric
}
if payload.Message == "" {
payload.Message = fallbackChatLastErrorMessage
+14 -6
View File
@@ -19,7 +19,6 @@ import (
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisionersdk/proto"
)
@@ -920,7 +919,7 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
lastErrorPayload := codersdk.ChatError{
Message: "boom",
Detail: "provider detail",
Kind: chaterror.KindGeneric,
Kind: codersdk.ChatErrorKindGeneric,
Provider: "openai",
Retryable: true,
StatusCode: 503,
@@ -1082,7 +1081,7 @@ func TestChat_LastErrorFallback(t *testing.T) {
raw: json.RawMessage(`{`),
expectPayload: &codersdk.ChatError{
Message: fallbackMessage,
Kind: chaterror.KindGeneric,
Kind: codersdk.ChatErrorKindGeneric,
Retryable: false,
},
},
@@ -1091,7 +1090,7 @@ func TestChat_LastErrorFallback(t *testing.T) {
raw: json.RawMessage(`{"kind":"timeout","provider":"openai","status_code":504}`),
expectPayload: &codersdk.ChatError{
Message: fallbackMessage,
Kind: "timeout",
Kind: codersdk.ChatErrorKindTimeout,
Provider: "openai",
Retryable: false,
StatusCode: 504,
@@ -1102,7 +1101,7 @@ func TestChat_LastErrorFallback(t *testing.T) {
raw: json.RawMessage(`{"message":" ","provider":"openai"}`),
expectPayload: &codersdk.ChatError{
Message: fallbackMessage,
Kind: chaterror.KindGeneric,
Kind: codersdk.ChatErrorKindGeneric,
Provider: "openai",
Retryable: false,
},
@@ -1112,12 +1111,21 @@ func TestChat_LastErrorFallback(t *testing.T) {
raw: json.RawMessage(`{"message":"OpenAI returned an unexpected error.","provider":"openai","status_code":502}`),
expectPayload: &codersdk.ChatError{
Message: "OpenAI returned an unexpected error.",
Kind: chaterror.KindGeneric,
Kind: codersdk.ChatErrorKindGeneric,
Provider: "openai",
Retryable: false,
StatusCode: 502,
},
},
{
name: "UsageLimitKindRoundTrips",
raw: json.RawMessage(`{"message":"Usage limit reached.","kind":"usage_limit"}`),
expectPayload: &codersdk.ChatError{
Message: "Usage limit reached.",
Kind: codersdk.ChatErrorKindUsageLimit,
Retryable: false,
},
},
}
for _, tc := range tests {
+2 -2
View File
@@ -5362,7 +5362,7 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
logger.Error(cleanupCtx, "panic during chat processing", slog.F("panic", r))
classified := chaterror.ClassifiedError{
Message: panicFailureReason(r),
Kind: chaterror.KindGeneric,
Kind: codersdk.ChatErrorKindGeneric,
}
lastErrorPayload = chaterror.TerminalErrorPayload(classified)
p.publishError(chat.ID, classified)
@@ -8014,7 +8014,7 @@ func (p *Server) recoverStaleChats(ctx context.Context) {
lastErrorPayload, marshalErr := encodeChatLastErrorPayload(
chaterror.TerminalErrorPayload(chaterror.ClassifiedError{
Message: "Dynamic tool execution timed out",
Kind: chaterror.KindGeneric,
Kind: codersdk.ChatErrorKindGeneric,
}),
)
if marshalErr != nil {
+3 -3
View File
@@ -2323,7 +2323,7 @@ func TestSubscribeDoesNotReplayRetryAfterTerminalError(t *testing.T) {
server.publishRetry(chatID, newTestRetryPayload())
server.publishError(chatID, chaterror.ClassifiedError{
Message: "OpenAI is rate limiting requests.",
Kind: chaterror.KindRateLimit,
Kind: codersdk.ChatErrorKindRateLimit,
Provider: "openai",
Retryable: true,
StatusCode: 429,
@@ -2399,7 +2399,7 @@ func TestSubscribePrefersStructuredErrorPayloadViaPubsub(t *testing.T) {
classified := chaterror.ClassifiedError{
Message: "OpenAI is rate limiting requests.",
Kind: chaterror.KindRateLimit,
Kind: codersdk.ChatErrorKindRateLimit,
Provider: "openai",
Retryable: true,
StatusCode: 429,
@@ -2449,7 +2449,7 @@ func TestSubscribeFallsBackToLegacyErrorStringViaPubsub(t *testing.T) {
func newTestRetryPayload() *codersdk.ChatStreamRetry {
payload := chaterror.StreamRetryPayload(1, 1500*time.Millisecond, chaterror.ClassifiedError{
Message: "OpenAI is rate limiting requests.",
Kind: chaterror.KindRateLimit,
Kind: codersdk.ChatErrorKindRateLimit,
Provider: "openai",
Retryable: true,
StatusCode: 429,
+4 -5
View File
@@ -46,7 +46,6 @@ import (
"github.com/coder/coder/v2/coderd/workspacestats"
"github.com/coder/coder/v2/coderd/x/chatd"
"github.com/coder/coder/v2/coderd/x/chatd/chatadvisor"
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
@@ -3690,7 +3689,7 @@ func TestRecoverStaleRequiresActionChat(t *testing.T) {
persistedError := requireChatLastErrorPayload(t, chatResult.LastError)
require.Equal(t, codersdk.ChatError{
Message: "Dynamic tool execution timed out",
Kind: chaterror.KindGeneric,
Kind: codersdk.ChatErrorKindGeneric,
}, persistedError)
require.False(t, chatResult.WorkerID.Valid)
}
@@ -3803,7 +3802,7 @@ func TestUpdateChatStatusPersistsLastError(t *testing.T) {
errorMessage := "stream response: status 500: internal server error"
wantPayload := codersdk.ChatError{
Message: errorMessage,
Kind: chaterror.KindGeneric,
Kind: codersdk.ChatErrorKindGeneric,
}
chat, err := db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
ID: chat.ID,
@@ -7077,7 +7076,7 @@ func TestProcessChat_UserProviderKey_MissingKeyError(t *testing.T) {
persistedError := requireChatLastErrorPayload(t, chatResult.LastError)
require.NotEmpty(t, persistedError.Message)
require.NotContains(t, persistedError.Message, "panicked")
require.Equal(t, chaterror.KindGeneric, persistedError.Kind)
require.Equal(t, codersdk.ChatErrorKindGeneric, persistedError.Kind)
require.NotEqual(t, database.ChatStatusRunning, chatResult.Status)
require.Zero(t, llmCalls.Load(), "missing user key should fail before any LLM request")
}
@@ -7142,7 +7141,7 @@ func TestProcessChatPanicRecovery(t *testing.T) {
persistedError := requireChatLastErrorPayload(t, chatResult.LastError)
require.Contains(t, persistedError.Message, "chat processing panicked")
require.Contains(t, persistedError.Message, "intentional test panic")
require.Equal(t, chaterror.KindGeneric, persistedError.Kind)
require.Equal(t, codersdk.ChatErrorKindGeneric, persistedError.Kind)
}
// panicOnInTxDB wraps a database.Store and panics on the first InTx
+17 -15
View File
@@ -5,6 +5,8 @@ import (
"errors"
"strings"
"time"
"github.com/coder/coder/v2/codersdk"
)
// ClassifiedError is the normalized, user-facing view of an
@@ -12,7 +14,7 @@ import (
type ClassifiedError struct {
Message string
Detail string
Kind string
Kind codersdk.ChatErrorKind
Provider string
Retryable bool
StatusCode int
@@ -117,7 +119,7 @@ func Classify(err error) ClassifiedError {
return normalizeClassification(ClassifiedError{
Message: "The request was canceled before it completed.",
Detail: structured.detail,
Kind: KindGeneric,
Kind: codersdk.ChatErrorKindGeneric,
Provider: provider,
StatusCode: statusCode,
RetryAfter: structured.retryAfter,
@@ -128,7 +130,7 @@ func Classify(err error) ClassifiedError {
return normalizeClassification(ClassifiedError{
Message: responsesAPIDiagnosticMessage,
Detail: detail,
Kind: KindGeneric,
Kind: codersdk.ChatErrorKindGeneric,
Provider: provider,
StatusCode: statusCode,
RetryAfter: structured.retryAfter,
@@ -154,42 +156,42 @@ func Classify(err error) ClassifiedError {
// the root cause when both signals appear.
rules := []struct {
match bool
kind string
kind codersdk.ChatErrorKind
retryable bool
}{
{
match: overloadedMatch,
kind: KindOverloaded,
kind: codersdk.ChatErrorKindOverloaded,
retryable: true,
},
{
match: authStrong,
kind: KindAuth,
kind: codersdk.ChatErrorKindAuth,
retryable: false,
},
{
match: authWeak && !configMatch,
kind: KindAuth,
kind: codersdk.ChatErrorKindAuth,
retryable: false,
},
{
match: rateLimitMatch && !configMatch,
kind: KindRateLimit,
kind: codersdk.ChatErrorKindRateLimit,
retryable: true,
},
{
match: timeoutMatch && !configMatch,
kind: KindTimeout,
kind: codersdk.ChatErrorKindTimeout,
retryable: !deadline,
},
{
match: configMatch,
kind: KindConfig,
kind: codersdk.ChatErrorKindConfig,
retryable: false,
},
{
match: genericRetryableMatch,
kind: KindGeneric,
kind: codersdk.ChatErrorKindGeneric,
retryable: true,
},
}
@@ -209,7 +211,7 @@ func Classify(err error) ClassifiedError {
return normalizeClassification(ClassifiedError{
Detail: structured.detail,
Kind: KindGeneric,
Kind: codersdk.ChatErrorKindGeneric,
Provider: provider,
StatusCode: statusCode,
RetryAfter: structured.retryAfter,
@@ -229,7 +231,7 @@ func responsesAPIDiagnostic(lowerMessage, detail string) (string, bool) {
func normalizeClassification(classified ClassifiedError) ClassifiedError {
classified.Message = strings.TrimSpace(classified.Message)
classified.Detail = normalizeClassificationDetail(classified.Detail)
classified.Kind = strings.TrimSpace(classified.Kind)
classified.Kind = codersdk.ChatErrorKind(strings.TrimSpace(string(classified.Kind)))
classified.Provider = normalizeProvider(classified.Provider)
if classified.RetryAfter < 0 {
classified.RetryAfter = 0
@@ -239,10 +241,10 @@ func normalizeClassification(classified ClassifiedError) ClassifiedError {
classified.RetryAfter <= 0 {
return ClassifiedError{}
}
classified.Kind = KindGeneric
classified.Kind = codersdk.ChatErrorKindGeneric
}
if classified.Kind == "" {
classified.Kind = KindGeneric
classified.Kind = codersdk.ChatErrorKindGeneric
}
if classified.Message == "" {
classified.Message = terminalMessage(classified)
+80 -79
View File
@@ -12,6 +12,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
"github.com/coder/coder/v2/codersdk"
)
func TestClassify(t *testing.T) {
@@ -27,7 +28,7 @@ func TestClassify(t *testing.T) {
err: xerrors.New("status 529 from upstream"),
want: chaterror.ClassifiedError{
Message: "The AI provider is temporarily overloaded.",
Kind: chaterror.KindOverloaded,
Kind: codersdk.ChatErrorKindOverloaded,
Provider: "",
Retryable: true,
StatusCode: 529,
@@ -38,7 +39,7 @@ func TestClassify(t *testing.T) {
err: xerrors.New("anthropic overloaded_error"),
want: chaterror.ClassifiedError{
Message: "Anthropic is temporarily overloaded.",
Kind: chaterror.KindOverloaded,
Kind: codersdk.ChatErrorKindOverloaded,
Provider: "anthropic",
Retryable: true,
StatusCode: 0,
@@ -49,7 +50,7 @@ func TestClassify(t *testing.T) {
err: xerrors.New("authentication failed: invalid model"),
want: chaterror.ClassifiedError{
Message: "Authentication with the AI provider failed. Check the API key, permissions, and billing settings.",
Kind: chaterror.KindAuth,
Kind: codersdk.ChatErrorKindAuth,
Provider: "",
Retryable: false,
StatusCode: 0,
@@ -60,7 +61,7 @@ func TestClassify(t *testing.T) {
err: xerrors.New("invalid model"),
want: chaterror.ClassifiedError{
Message: "The AI provider rejected the model configuration. Check the selected model and provider settings.",
Kind: chaterror.KindConfig,
Kind: codersdk.ChatErrorKindConfig,
Provider: "",
Retryable: false,
StatusCode: 0,
@@ -71,7 +72,7 @@ func TestClassify(t *testing.T) {
err: xerrors.New("forbidden"),
want: chaterror.ClassifiedError{
Message: "Authentication with the AI provider failed. Check the API key, permissions, and billing settings.",
Kind: chaterror.KindAuth,
Kind: codersdk.ChatErrorKindAuth,
Provider: "",
Retryable: false,
StatusCode: 0,
@@ -82,7 +83,7 @@ func TestClassify(t *testing.T) {
err: xerrors.New("status 401 from upstream"),
want: chaterror.ClassifiedError{
Message: "Authentication with the AI provider failed. Check the API key, permissions, and billing settings.",
Kind: chaterror.KindAuth,
Kind: codersdk.ChatErrorKindAuth,
Provider: "",
Retryable: false,
StatusCode: 401,
@@ -93,7 +94,7 @@ func TestClassify(t *testing.T) {
err: xerrors.New("status 403 from upstream"),
want: chaterror.ClassifiedError{
Message: "Authentication with the AI provider failed. Check the API key, permissions, and billing settings.",
Kind: chaterror.KindAuth,
Kind: codersdk.ChatErrorKindAuth,
Provider: "",
Retryable: false,
StatusCode: 403,
@@ -104,7 +105,7 @@ func TestClassify(t *testing.T) {
err: xerrors.New("forbidden: context length exceeded"),
want: chaterror.ClassifiedError{
Message: "The AI provider rejected the model configuration. Check the selected model and provider settings.",
Kind: chaterror.KindConfig,
Kind: codersdk.ChatErrorKindConfig,
Provider: "",
Retryable: false,
StatusCode: 0,
@@ -115,7 +116,7 @@ func TestClassify(t *testing.T) {
err: xerrors.New("status 429 from upstream"),
want: chaterror.ClassifiedError{
Message: "The AI provider is rate limiting requests.",
Kind: chaterror.KindRateLimit,
Kind: codersdk.ChatErrorKindRateLimit,
Provider: "",
Retryable: true,
StatusCode: 429,
@@ -126,7 +127,7 @@ func TestClassify(t *testing.T) {
err: xerrors.New("status 429: invalid model"),
want: chaterror.ClassifiedError{
Message: "The AI provider rejected the model configuration. Check the selected model and provider settings.",
Kind: chaterror.KindConfig,
Kind: codersdk.ChatErrorKindConfig,
Provider: "",
Retryable: false,
StatusCode: 429,
@@ -137,7 +138,7 @@ func TestClassify(t *testing.T) {
err: xerrors.New("service unavailable"),
want: chaterror.ClassifiedError{
Message: "The AI provider is temporarily unavailable.",
Kind: chaterror.KindTimeout,
Kind: codersdk.ChatErrorKindTimeout,
Provider: "",
Retryable: true,
StatusCode: 0,
@@ -148,7 +149,7 @@ func TestClassify(t *testing.T) {
err: xerrors.New("status 503: invalid model"),
want: chaterror.ClassifiedError{
Message: "The AI provider rejected the model configuration. Check the selected model and provider settings.",
Kind: chaterror.KindConfig,
Kind: codersdk.ChatErrorKindConfig,
Provider: "",
Retryable: false,
StatusCode: 503,
@@ -159,7 +160,7 @@ func TestClassify(t *testing.T) {
err: xerrors.New("service unavailable: model not found"),
want: chaterror.ClassifiedError{
Message: "The AI provider rejected the model configuration. Check the selected model and provider settings.",
Kind: chaterror.KindConfig,
Kind: codersdk.ChatErrorKindConfig,
Provider: "",
Retryable: false,
StatusCode: 0,
@@ -170,7 +171,7 @@ func TestClassify(t *testing.T) {
err: xerrors.New("connection refused: unsupported model"),
want: chaterror.ClassifiedError{
Message: "The AI provider rejected the model configuration. Check the selected model and provider settings.",
Kind: chaterror.KindConfig,
Kind: codersdk.ChatErrorKindConfig,
Provider: "",
Retryable: false,
StatusCode: 0,
@@ -181,7 +182,7 @@ func TestClassify(t *testing.T) {
err: context.DeadlineExceeded,
want: chaterror.ClassifiedError{
Message: "The request timed out before it completed.",
Kind: chaterror.KindTimeout,
Kind: codersdk.ChatErrorKindTimeout,
Provider: "",
Retryable: false,
StatusCode: 0,
@@ -243,7 +244,7 @@ func TestClassify_OpenAIResponsesAPIDiagnostics(t *testing.T) {
t.Parallel()
classified := chaterror.Classify(xerrors.New(tt.err))
require.Equal(t, chaterror.KindGeneric, classified.Kind)
require.Equal(t, codersdk.ChatErrorKindGeneric, classified.Kind)
require.False(t, classified.Retryable)
require.Zero(t, classified.StatusCode)
assertDirectionalMessage(t, classified.Message)
@@ -263,7 +264,7 @@ func TestClassify_OpenAIResponsesAPIDiagnostics(t *testing.T) {
testProviderResponseDump(tt.responseBody),
),
))
require.Equal(t, chaterror.KindGeneric, classified.Kind)
require.Equal(t, codersdk.ChatErrorKindGeneric, classified.Kind)
require.False(t, classified.Retryable)
require.Equal(t, 400, classified.StatusCode)
assertDirectionalMessage(t, classified.Message)
@@ -279,54 +280,54 @@ func TestClassify_PatternCoverage(t *testing.T) {
tests := []struct {
name string
err string
wantKind string
wantKind codersdk.ChatErrorKind
wantRetry bool
}{
{name: "OverloadedLiteral", err: "overloaded", wantKind: chaterror.KindOverloaded, wantRetry: true},
{name: "RateLimitLiteral", err: "rate limit", wantKind: chaterror.KindRateLimit, wantRetry: true},
{name: "RateLimitUnderscoreLiteral", err: "rate_limit", wantKind: chaterror.KindRateLimit, wantRetry: true},
{name: "RateLimitedLiteral", err: "rate limited", wantKind: chaterror.KindRateLimit, wantRetry: true},
{name: "RateLimitedHyphenLiteral", err: "rate-limited", wantKind: chaterror.KindRateLimit, wantRetry: true},
{name: "TooManyRequestsLiteral", err: "too many requests", wantKind: chaterror.KindRateLimit, wantRetry: true},
{name: "TimeoutLiteral", err: "timeout", wantKind: chaterror.KindTimeout, wantRetry: true},
{name: "TimedOutLiteral", err: "timed out", wantKind: chaterror.KindTimeout, wantRetry: true},
{name: "ServiceUnavailableLiteral", err: "service unavailable", wantKind: chaterror.KindTimeout, wantRetry: true},
{name: "UnavailableLiteral", err: "unavailable", wantKind: chaterror.KindTimeout, wantRetry: true},
{name: "ConnectionResetLiteral", err: "connection reset", wantKind: chaterror.KindTimeout, wantRetry: true},
{name: "ConnectionRefusedLiteral", err: "connection refused", wantKind: chaterror.KindTimeout, wantRetry: true},
{name: "EOFLiteral", err: "eof", wantKind: chaterror.KindTimeout, wantRetry: true},
{name: "BrokenPipeLiteral", err: "broken pipe", wantKind: chaterror.KindTimeout, wantRetry: true},
{name: "BadGatewayLiteral", err: "bad gateway", wantKind: chaterror.KindTimeout, wantRetry: true},
{name: "GatewayTimeoutLiteral", err: "gateway timeout", wantKind: chaterror.KindTimeout, wantRetry: true},
{name: "ClientConnLiteral", err: "client conn", wantKind: chaterror.KindTimeout, wantRetry: true},
{name: "GOAWAYLiteral", err: "goaway", wantKind: chaterror.KindTimeout, wantRetry: true},
{name: "HTTP2StreamClosedLiteral", err: "http2: stream closed", wantKind: chaterror.KindTimeout, wantRetry: true},
{name: "UseOfClosedNetworkConnectionLiteral", err: "use of closed network connection", wantKind: chaterror.KindTimeout, wantRetry: true},
{name: "AuthenticationLiteral", err: "authentication", wantKind: chaterror.KindAuth, wantRetry: false},
{name: "UnauthorizedLiteral", err: "unauthorized", wantKind: chaterror.KindAuth, wantRetry: false},
{name: "InvalidAPIKeyLiteral", err: "invalid api key", wantKind: chaterror.KindAuth, wantRetry: false},
{name: "InvalidAPIKeyUnderscoreLiteral", err: "invalid_api_key", wantKind: chaterror.KindAuth, wantRetry: false},
{name: "QuotaLiteral", err: "quota", wantKind: chaterror.KindAuth, wantRetry: false},
{name: "BillingLiteral", err: "billing", wantKind: chaterror.KindAuth, wantRetry: false},
{name: "InsufficientQuotaLiteral", err: "insufficient_quota", wantKind: chaterror.KindAuth, wantRetry: false},
{name: "PaymentRequiredLiteral", err: "payment required", wantKind: chaterror.KindAuth, wantRetry: false},
{name: "ForbiddenLiteral", err: "forbidden", wantKind: chaterror.KindAuth, wantRetry: false},
{name: "InvalidModelLiteral", err: "invalid model", wantKind: chaterror.KindConfig, wantRetry: false},
{name: "ModelNotFoundLiteral", err: "model not found", wantKind: chaterror.KindConfig, wantRetry: false},
{name: "ModelNotFoundUnderscoreLiteral", err: "model_not_found", wantKind: chaterror.KindConfig, wantRetry: false},
{name: "UnsupportedModelLiteral", err: "unsupported model", wantKind: chaterror.KindConfig, wantRetry: false},
{name: "ContextLengthExceededLiteral", err: "context length exceeded", wantKind: chaterror.KindConfig, wantRetry: false},
{name: "ContextExceededLiteral", err: "context_exceeded", wantKind: chaterror.KindConfig, wantRetry: false},
{name: "MaximumContextLengthLiteral", err: "maximum context length", wantKind: chaterror.KindConfig, wantRetry: false},
{name: "MalformedConfigLiteral", err: "malformed config", wantKind: chaterror.KindConfig, wantRetry: false},
{name: "MalformedConfigurationLiteral", err: "malformed configuration", wantKind: chaterror.KindConfig, wantRetry: false},
{name: "ServerErrorLiteral", err: "server error", wantKind: chaterror.KindGeneric, wantRetry: true},
{name: "InternalServerErrorLiteral", err: "internal server error", wantKind: chaterror.KindGeneric, wantRetry: true},
{name: "ChatInterruptedLiteral", err: "chat interrupted", wantKind: chaterror.KindGeneric, wantRetry: false},
{name: "RequestInterruptedLiteral", err: "request interrupted", wantKind: chaterror.KindGeneric, wantRetry: false},
{name: "OperationInterruptedLiteral", err: "operation interrupted", wantKind: chaterror.KindGeneric, wantRetry: false},
{name: "Status408", err: "status 408", wantKind: chaterror.KindTimeout, wantRetry: true},
{name: "Status500", err: "status 500", wantKind: chaterror.KindGeneric, wantRetry: true},
{name: "OverloadedLiteral", err: "overloaded", wantKind: codersdk.ChatErrorKindOverloaded, wantRetry: true},
{name: "RateLimitLiteral", err: "rate limit", wantKind: codersdk.ChatErrorKindRateLimit, wantRetry: true},
{name: "RateLimitUnderscoreLiteral", err: "rate_limit", wantKind: codersdk.ChatErrorKindRateLimit, wantRetry: true},
{name: "RateLimitedLiteral", err: "rate limited", wantKind: codersdk.ChatErrorKindRateLimit, wantRetry: true},
{name: "RateLimitedHyphenLiteral", err: "rate-limited", wantKind: codersdk.ChatErrorKindRateLimit, wantRetry: true},
{name: "TooManyRequestsLiteral", err: "too many requests", wantKind: codersdk.ChatErrorKindRateLimit, wantRetry: true},
{name: "TimeoutLiteral", err: "timeout", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true},
{name: "TimedOutLiteral", err: "timed out", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true},
{name: "ServiceUnavailableLiteral", err: "service unavailable", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true},
{name: "UnavailableLiteral", err: "unavailable", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true},
{name: "ConnectionResetLiteral", err: "connection reset", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true},
{name: "ConnectionRefusedLiteral", err: "connection refused", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true},
{name: "EOFLiteral", err: "eof", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true},
{name: "BrokenPipeLiteral", err: "broken pipe", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true},
{name: "BadGatewayLiteral", err: "bad gateway", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true},
{name: "GatewayTimeoutLiteral", err: "gateway timeout", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true},
{name: "ClientConnLiteral", err: "client conn", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true},
{name: "GOAWAYLiteral", err: "goaway", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true},
{name: "HTTP2StreamClosedLiteral", err: "http2: stream closed", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true},
{name: "UseOfClosedNetworkConnectionLiteral", err: "use of closed network connection", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true},
{name: "AuthenticationLiteral", err: "authentication", wantKind: codersdk.ChatErrorKindAuth, wantRetry: false},
{name: "UnauthorizedLiteral", err: "unauthorized", wantKind: codersdk.ChatErrorKindAuth, wantRetry: false},
{name: "InvalidAPIKeyLiteral", err: "invalid api key", wantKind: codersdk.ChatErrorKindAuth, wantRetry: false},
{name: "InvalidAPIKeyUnderscoreLiteral", err: "invalid_api_key", wantKind: codersdk.ChatErrorKindAuth, wantRetry: false},
{name: "QuotaLiteral", err: "quota", wantKind: codersdk.ChatErrorKindAuth, wantRetry: false},
{name: "BillingLiteral", err: "billing", wantKind: codersdk.ChatErrorKindAuth, wantRetry: false},
{name: "InsufficientQuotaLiteral", err: "insufficient_quota", wantKind: codersdk.ChatErrorKindAuth, wantRetry: false},
{name: "PaymentRequiredLiteral", err: "payment required", wantKind: codersdk.ChatErrorKindAuth, wantRetry: false},
{name: "ForbiddenLiteral", err: "forbidden", wantKind: codersdk.ChatErrorKindAuth, wantRetry: false},
{name: "InvalidModelLiteral", err: "invalid model", wantKind: codersdk.ChatErrorKindConfig, wantRetry: false},
{name: "ModelNotFoundLiteral", err: "model not found", wantKind: codersdk.ChatErrorKindConfig, wantRetry: false},
{name: "ModelNotFoundUnderscoreLiteral", err: "model_not_found", wantKind: codersdk.ChatErrorKindConfig, wantRetry: false},
{name: "UnsupportedModelLiteral", err: "unsupported model", wantKind: codersdk.ChatErrorKindConfig, wantRetry: false},
{name: "ContextLengthExceededLiteral", err: "context length exceeded", wantKind: codersdk.ChatErrorKindConfig, wantRetry: false},
{name: "ContextExceededLiteral", err: "context_exceeded", wantKind: codersdk.ChatErrorKindConfig, wantRetry: false},
{name: "MaximumContextLengthLiteral", err: "maximum context length", wantKind: codersdk.ChatErrorKindConfig, wantRetry: false},
{name: "MalformedConfigLiteral", err: "malformed config", wantKind: codersdk.ChatErrorKindConfig, wantRetry: false},
{name: "MalformedConfigurationLiteral", err: "malformed configuration", wantKind: codersdk.ChatErrorKindConfig, wantRetry: false},
{name: "ServerErrorLiteral", err: "server error", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: true},
{name: "InternalServerErrorLiteral", err: "internal server error", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: true},
{name: "ChatInterruptedLiteral", err: "chat interrupted", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: false},
{name: "RequestInterruptedLiteral", err: "request interrupted", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: false},
{name: "OperationInterruptedLiteral", err: "operation interrupted", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: false},
{name: "Status408", err: "status 408", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true},
{name: "Status500", err: "status 500", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: true},
}
for _, tt := range tests {
@@ -359,7 +360,7 @@ func TestClassify_TransportFailuresUseBroaderRetryMessage(t *testing.T) {
t.Parallel()
classified := chaterror.Classify(xerrors.New(tt.err))
require.Equal(t, chaterror.KindTimeout, classified.Kind)
require.Equal(t, codersdk.ChatErrorKindTimeout, classified.Kind)
require.True(t, classified.Retryable)
require.Equal(
t,
@@ -371,7 +372,7 @@ func TestClassify_TransportFailuresUseBroaderRetryMessage(t *testing.T) {
}
// TestClassify_HTTP2TransportErrors checks HTTP/2 transport errors
// classify as retryable KindTimeout. Split into two sub-tables so a
// classify as retryable ChatErrorKindTimeout. Split into two sub-tables so a
// bug in transport matching cannot be masked by provider detection
// (and vice versa).
func TestClassify_HTTP2TransportErrors(t *testing.T) {
@@ -426,7 +427,7 @@ func TestClassify_HTTP2TransportErrors(t *testing.T) {
t.Parallel()
classified := chaterror.Classify(xerrors.New(tt.err))
require.Equal(t, chaterror.KindTimeout, classified.Kind, "Kind")
require.Equal(t, codersdk.ChatErrorKindTimeout, classified.Kind, "Kind")
require.True(t, classified.Retryable, "Retryable")
require.Equal(t, "", classified.Provider, "Provider")
require.Equal(t,
@@ -470,7 +471,7 @@ func TestClassify_HTTP2TransportErrors(t *testing.T) {
t.Parallel()
classified := chaterror.Classify(xerrors.New(tt.err))
require.Equal(t, chaterror.KindTimeout, classified.Kind, "Kind")
require.Equal(t, codersdk.ChatErrorKindTimeout, classified.Kind, "Kind")
require.True(t, classified.Retryable, "Retryable")
require.Equal(t, tt.provider, classified.Provider, "Provider")
require.Equal(t, tt.wantMessage, classified.Message, "Message")
@@ -486,35 +487,35 @@ func TestClassify_StatusCodeBeatsHTTP2Transport(t *testing.T) {
tests := []struct {
name string
err string
wantKind string
wantKind codersdk.ChatErrorKind
wantRetryable bool
wantStatus int
}{
{
name: "HTTP2With429",
err: "http2: server error 429 Too Many Requests",
wantKind: chaterror.KindRateLimit,
wantKind: codersdk.ChatErrorKindRateLimit,
wantRetryable: true,
wantStatus: 429,
},
{
name: "HTTP2With401",
err: "http2: 401 unauthorized",
wantKind: chaterror.KindAuth,
wantKind: codersdk.ChatErrorKindAuth,
wantRetryable: false,
wantStatus: 401,
},
{
name: "ClientConnWith429RateLimitWins",
err: "http2: client conn is closed: status 429 Too Many Requests",
wantKind: chaterror.KindRateLimit,
wantKind: codersdk.ChatErrorKindRateLimit,
wantRetryable: true,
wantStatus: 429,
},
{
name: "GOAWAYWith401AuthWins",
err: "http2: server sent GOAWAY: status 401 unauthorized",
wantKind: chaterror.KindAuth,
wantKind: codersdk.ChatErrorKindAuth,
wantRetryable: false,
wantStatus: 401,
},
@@ -538,7 +539,7 @@ func TestClassify_StartupTimeoutWrappedClassificationWins(t *testing.T) {
wrapped := chaterror.WithClassification(
xerrors.New("context canceled"),
chaterror.ClassifiedError{
Kind: chaterror.KindStartupTimeout,
Kind: codersdk.ChatErrorKindStartupTimeout,
Provider: "openai",
Retryable: true,
},
@@ -546,7 +547,7 @@ func TestClassify_StartupTimeoutWrappedClassificationWins(t *testing.T) {
require.Equal(t, chaterror.ClassifiedError{
Message: "OpenAI did not start responding in time.",
Kind: chaterror.KindStartupTimeout,
Kind: codersdk.ChatErrorKindStartupTimeout,
Provider: "openai",
Retryable: true,
StatusCode: 0,
@@ -562,7 +563,7 @@ func TestWithProviderUsesExplicitHint(t *testing.T) {
enriched := classified.WithProvider("azure openai")
require.Equal(t, chaterror.ClassifiedError{
Message: "Azure OpenAI is rate limiting requests.",
Kind: chaterror.KindRateLimit,
Kind: codersdk.ChatErrorKindRateLimit,
Provider: "azure",
Retryable: true,
StatusCode: 429,
@@ -578,7 +579,7 @@ func TestWithProviderAddsProviderWhenUnknown(t *testing.T) {
enriched := classified.WithProvider("openai")
require.Equal(t, chaterror.ClassifiedError{
Message: "OpenAI is rate limiting requests.",
Kind: chaterror.KindRateLimit,
Kind: codersdk.ChatErrorKindRateLimit,
Provider: "openai",
Retryable: true,
StatusCode: 429,
@@ -596,7 +597,7 @@ func TestClassify_UsesStructuredProviderStatusAndRetryAfter(t *testing.T) {
require.Equal(t, chaterror.ClassifiedError{
Message: "The AI provider is rate limiting requests.",
Kind: chaterror.KindRateLimit,
Kind: codersdk.ChatErrorKindRateLimit,
Provider: "",
Retryable: true,
StatusCode: 429,
@@ -660,7 +661,7 @@ func TestWithProviderPreservesRetryAfter(t *testing.T) {
require.Equal(t, 30*time.Second, enriched.RetryAfter)
require.Equal(t, chaterror.ClassifiedError{
Message: "OpenAI is rate limiting requests.",
Kind: chaterror.KindRateLimit,
Kind: codersdk.ChatErrorKindRateLimit,
Provider: "openai",
Retryable: true,
StatusCode: 429,
@@ -681,7 +682,7 @@ func TestClassify_UsesStructuredProviderDetailFromResponseDump(t *testing.T) {
require.Equal(t, chaterror.ClassifiedError{
Message: "The AI provider returned an unexpected error.",
Detail: "Image exceeds 5 MB maximum.",
Kind: chaterror.KindGeneric,
Kind: codersdk.ChatErrorKindGeneric,
Provider: "",
Retryable: false,
StatusCode: 400,
-13
View File
@@ -1,13 +0,0 @@
// Package chaterror classifies provider/runtime failures into stable,
// user-facing chat error payloads.
package chaterror
const (
KindOverloaded = "overloaded"
KindRateLimit = "rate_limit"
KindTimeout = "timeout"
KindStartupTimeout = "startup_timeout"
KindAuth = "auth"
KindConfig = "config"
KindGeneric = "generic"
)
+14 -12
View File
@@ -3,6 +3,8 @@ package chaterror
import (
"fmt"
"strings"
"github.com/coder/coder/v2/codersdk"
)
// terminalMessage produces the user-facing error description shown
@@ -13,24 +15,24 @@ import (
func terminalMessage(classified ClassifiedError) string {
subject := providerSubject(classified.Provider)
switch classified.Kind {
case KindOverloaded:
case codersdk.ChatErrorKindOverloaded:
return fmt.Sprintf("%s is temporarily overloaded.", subject)
case KindRateLimit:
case codersdk.ChatErrorKindRateLimit:
return fmt.Sprintf("%s is rate limiting requests.", subject)
case KindTimeout:
case codersdk.ChatErrorKindTimeout:
if !classified.Retryable && classified.StatusCode == 0 {
return "The request timed out before it completed."
}
return fmt.Sprintf("%s is temporarily unavailable.", subject)
case KindStartupTimeout:
case codersdk.ChatErrorKindStartupTimeout:
return fmt.Sprintf(
"%s did not start responding in time.", subject,
)
case KindAuth:
case codersdk.ChatErrorKindAuth:
displayName := providerDisplayName(classified.Provider)
if displayName == "" {
displayName = "the AI provider"
@@ -41,7 +43,7 @@ func terminalMessage(classified ClassifiedError) string {
displayName,
)
case KindConfig:
case codersdk.ChatErrorKindConfig:
return fmt.Sprintf(
"%s rejected the model configuration."+
" Check the selected model and provider settings.",
@@ -63,17 +65,17 @@ func terminalMessage(classified ClassifiedError) string {
func retryMessage(classified ClassifiedError) string {
subject := providerSubject(classified.Provider)
switch classified.Kind {
case KindOverloaded:
case codersdk.ChatErrorKindOverloaded:
return fmt.Sprintf("%s is temporarily overloaded.", subject)
case KindRateLimit:
case codersdk.ChatErrorKindRateLimit:
return fmt.Sprintf("%s is rate limiting requests.", subject)
case KindTimeout:
case codersdk.ChatErrorKindTimeout:
return fmt.Sprintf("%s is temporarily unavailable.", subject)
case KindStartupTimeout:
case codersdk.ChatErrorKindStartupTimeout:
return fmt.Sprintf(
"%s did not start responding in time.", subject,
)
case KindAuth:
case codersdk.ChatErrorKindAuth:
displayName := providerDisplayName(classified.Provider)
if displayName == "" {
displayName = "the AI provider"
@@ -81,7 +83,7 @@ func retryMessage(classified ClassifiedError) string {
return fmt.Sprintf(
"Authentication with %s failed.", displayName,
)
case KindConfig:
case codersdk.ChatErrorKindConfig:
return fmt.Sprintf(
"%s rejected the model configuration.", subject,
)
+9 -8
View File
@@ -7,6 +7,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
"github.com/coder/coder/v2/codersdk"
)
// TestTerminalMessage covers the per-provider "temporarily
@@ -18,7 +19,7 @@ func TestTerminalMessage(t *testing.T) {
tests := []struct {
name string
kind string
kind codersdk.ChatErrorKind
provider string
retryable bool
statusCode int
@@ -26,42 +27,42 @@ func TestTerminalMessage(t *testing.T) {
}{
{
name: "Timeout_Retryable_Anthropic",
kind: chaterror.KindTimeout,
kind: codersdk.ChatErrorKindTimeout,
provider: "anthropic",
retryable: true,
want: "Anthropic is temporarily unavailable.",
},
{
name: "Timeout_Retryable_OpenAI",
kind: chaterror.KindTimeout,
kind: codersdk.ChatErrorKindTimeout,
provider: "openai",
retryable: true,
want: "OpenAI is temporarily unavailable.",
},
{
name: "Timeout_Retryable_UnknownProvider",
kind: chaterror.KindTimeout,
kind: codersdk.ChatErrorKindTimeout,
provider: "",
retryable: true,
want: "The AI provider is temporarily unavailable.",
},
{
name: "Timeout_NotRetryable_NoStatus",
kind: chaterror.KindTimeout,
kind: codersdk.ChatErrorKindTimeout,
provider: "",
retryable: false,
want: "The request timed out before it completed.",
},
{
name: "StartupTimeout_Anthropic",
kind: chaterror.KindStartupTimeout,
kind: codersdk.ChatErrorKindStartupTimeout,
provider: "anthropic",
retryable: true,
want: "Anthropic did not start responding in time.",
},
{
name: "StartupTimeout_OpenAI",
kind: chaterror.KindStartupTimeout,
kind: codersdk.ChatErrorKindStartupTimeout,
provider: "openai",
retryable: true,
want: "OpenAI did not start responding in time.",
@@ -70,7 +71,7 @@ func TestTerminalMessage(t *testing.T) {
// Generic fallback reserved for genuinely
// unclassified non-retryable failures.
name: "Generic_NotRetryable_NoStatus",
kind: chaterror.KindGeneric,
kind: codersdk.ChatErrorKindGeneric,
provider: "",
retryable: false,
want: "The chat request failed unexpectedly.",
+3 -3
View File
@@ -21,7 +21,7 @@ func TestTerminalErrorPayloadUsesNormalizedClassification(t *testing.T) {
require.Equal(t, &codersdk.ChatError{
Message: "Azure OpenAI is rate limiting requests.",
Kind: chaterror.KindRateLimit,
Kind: codersdk.ChatErrorKindRateLimit,
Provider: "azure",
Retryable: true,
StatusCode: 429,
@@ -54,7 +54,7 @@ func TestStreamRetryPayloadUsesNormalizedClassification(t *testing.T) {
startedAt := time.Now()
payload := chaterror.StreamRetryPayload(2, delay, chaterror.ClassifiedError{
Message: "OpenAI returned an unexpected error.",
Kind: chaterror.KindGeneric,
Kind: codersdk.ChatErrorKindGeneric,
Provider: "openai",
Retryable: true,
StatusCode: 503,
@@ -66,7 +66,7 @@ func TestStreamRetryPayloadUsesNormalizedClassification(t *testing.T) {
// Retry messages omit the HTTP status code; the status code is
// surfaced separately in the payload's StatusCode field.
require.Equal(t, "OpenAI returned an unexpected error.", payload.Error)
require.Equal(t, chaterror.KindGeneric, payload.Kind)
require.Equal(t, codersdk.ChatErrorKindGeneric, payload.Kind)
require.Equal(t, "openai", payload.Provider)
require.Equal(t, 503, payload.StatusCode)
require.WithinDuration(t, startedAt.Add(delay), payload.RetryingAt, time.Second)
+1 -1
View File
@@ -710,7 +710,7 @@ func classifyStartupTimeout(
err = errStartupTimeout
}
return chaterror.WithClassification(err, chaterror.ClassifiedError{
Kind: chaterror.KindStartupTimeout,
Kind: codersdk.ChatErrorKindStartupTimeout,
Provider: provider,
Retryable: true,
})
+6 -6
View File
@@ -571,7 +571,7 @@ func TestRun_OnRetryEnrichesProvider(t *testing.T) {
require.Equal(t, "received status 429 from upstream", records[0].errMsg)
require.Equal(t, chatretry.Delay(0), records[0].delay)
require.Equal(t, "openai", records[0].classified.Provider)
require.Equal(t, chaterror.KindRateLimit, records[0].classified.Kind)
require.Equal(t, codersdk.ChatErrorKindRateLimit, records[0].classified.Kind)
require.True(t, records[0].classified.Retryable)
require.Equal(t, 429, records[0].classified.StatusCode)
require.Equal(
@@ -633,7 +633,7 @@ func TestStartupGuard_DisarmPreservesPermanentError(t *testing.T) {
"openai",
xerrors.New("invalid model"),
))
require.Equal(t, chaterror.KindConfig, classified.Kind)
require.Equal(t, codersdk.ChatErrorKindConfig, classified.Kind)
require.False(t, classified.Retryable)
require.Nil(t, context.Cause(attemptCtx))
}
@@ -700,7 +700,7 @@ func TestRun_RetriesStartupTimeoutWhileOpeningStream(t *testing.T) {
require.NoError(t, awaitRunResult(ctx, t, done))
require.Equal(t, 2, attempts)
require.Len(t, retries, 1)
require.Equal(t, chaterror.KindStartupTimeout, retries[0].Kind)
require.Equal(t, codersdk.ChatErrorKindStartupTimeout, retries[0].Kind)
require.True(t, retries[0].Retryable)
require.Equal(t, "openai", retries[0].Provider)
require.Equal(
@@ -788,7 +788,7 @@ func TestRun_HTTP2TransportErrorClassifiedAsRetryableTimeout(t *testing.T) {
require.NoError(t, awaitRunResult(ctx, t, done))
require.Equal(t, 2, attempts)
require.Len(t, retries, 1)
require.Equal(t, chaterror.KindTimeout, retries[0].Kind, "Kind")
require.Equal(t, codersdk.ChatErrorKindTimeout, retries[0].Kind, "Kind")
require.True(t, retries[0].Retryable, "Retryable")
require.Equal(t, provider, retries[0].Provider, "Provider")
})
@@ -862,7 +862,7 @@ func TestRun_RetriesStartupTimeoutBeforeFirstPart(t *testing.T) {
require.NoError(t, awaitRunResult(ctx, t, done))
require.Equal(t, 2, attempts)
require.Len(t, retries, 1)
require.Equal(t, chaterror.KindStartupTimeout, retries[0].Kind)
require.Equal(t, codersdk.ChatErrorKindStartupTimeout, retries[0].Kind)
require.True(t, retries[0].Retryable)
require.Equal(t, "openai", retries[0].Provider)
require.Equal(
@@ -1077,7 +1077,7 @@ func TestRun_RetriesStartupTimeoutWhenStreamClosesSilently(t *testing.T) {
require.NoError(t, awaitRunResult(ctx, t, done))
require.Equal(t, 2, attempts)
require.Len(t, retries, 1)
require.Equal(t, chaterror.KindStartupTimeout, retries[0].Kind)
require.Equal(t, codersdk.ChatErrorKindStartupTimeout, retries[0].Kind)
require.True(t, retries[0].Retryable)
require.Equal(t, "openai", retries[0].Provider)
require.Equal(
+1 -1
View File
@@ -145,7 +145,7 @@ func (m *Metrics) RecordStreamRetry(provider, model string, classified chaterror
if m == nil {
return
}
m.StreamRetriesTotal.WithLabelValues(provider, model, classified.Kind).Inc()
m.StreamRetriesTotal.WithLabelValues(provider, model, string(classified.Kind)).Inc()
}
// RecordToolError increments tool_errors_total for the given
+16 -15
View File
@@ -16,6 +16,7 @@ import (
"github.com/coder/coder/v2/coderd/x/chatd/chatloop"
"github.com/coder/coder/v2/coderd/x/chatd/chatretry"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/codersdk"
)
func TestNewMetrics_RegistersAllMetrics(t *testing.T) {
@@ -33,7 +34,7 @@ func TestNewMetrics_RegistersAllMetrics(t *testing.T) {
m.PromptSizeBytes.WithLabelValues("anthropic", "claude-sonnet-4-5")
m.TTFTSeconds.WithLabelValues("anthropic", "claude-sonnet-4-5")
m.StepsTotal.WithLabelValues("anthropic", "claude-sonnet-4-5")
m.StreamRetriesTotal.WithLabelValues("anthropic", "claude-sonnet-4-5", chaterror.KindTimeout)
m.StreamRetriesTotal.WithLabelValues("anthropic", "claude-sonnet-4-5", string(codersdk.ChatErrorKindTimeout))
// StreamBufferDroppedTotal is a plain Counter, so it's always present
// in Gather output once registered; no exerciser call is
// needed.
@@ -87,14 +88,14 @@ func TestNopMetrics_DoesNotPanic(t *testing.T) {
m.CompactionTotal.WithLabelValues("openai", "gpt-5", "error").Inc()
m.CompactionTotal.WithLabelValues("google", "gemini-2.5-pro", "timeout").Inc()
m.StepsTotal.WithLabelValues("anthropic", "claude-sonnet-4-5").Inc()
m.StreamRetriesTotal.WithLabelValues("anthropic", "claude-sonnet-4-5", chaterror.KindTimeout).Inc()
m.StreamRetriesTotal.WithLabelValues("anthropic", "claude-sonnet-4-5", string(codersdk.ChatErrorKindTimeout)).Inc()
m.StreamBufferDroppedTotal.Inc()
// Nil-receiver guard for RecordStreamRetry and
// RecordStreamBufferDropped mirrors the existing RecordCompaction nil
// guard.
var nilMetrics *chatloop.Metrics
nilMetrics.RecordStreamRetry("anthropic", "claude-sonnet-4-5", chaterror.ClassifiedError{Kind: chaterror.KindTimeout})
nilMetrics.RecordStreamRetry("anthropic", "claude-sonnet-4-5", chaterror.ClassifiedError{Kind: codersdk.ChatErrorKindTimeout})
nilMetrics.RecordStreamBufferDropped()
nilMetrics.RecordToolError("anthropic", "claude-sonnet-4-5", "test")
}
@@ -279,21 +280,21 @@ func TestRecordCompaction(t *testing.T) {
func TestRecordStreamRetry(t *testing.T) {
t.Parallel()
// One row per chaterror.Kind* constant. Production callers always
// One row per ChatErrorKind constant. Production callers always
// reach RecordStreamRetry through chaterror.Classify, which
// guarantees Kind is non-empty, so no empty-string case is
// needed.
tests := []struct {
name string
kind string
kind codersdk.ChatErrorKind
}{
{name: "overloaded", kind: chaterror.KindOverloaded},
{name: "rate_limit", kind: chaterror.KindRateLimit},
{name: "timeout", kind: chaterror.KindTimeout},
{name: "startup_timeout", kind: chaterror.KindStartupTimeout},
{name: "auth", kind: chaterror.KindAuth},
{name: "config", kind: chaterror.KindConfig},
{name: "generic", kind: chaterror.KindGeneric},
{name: "overloaded", kind: codersdk.ChatErrorKindOverloaded},
{name: "rate_limit", kind: codersdk.ChatErrorKindRateLimit},
{name: "timeout", kind: codersdk.ChatErrorKindTimeout},
{name: "startup_timeout", kind: codersdk.ChatErrorKindStartupTimeout},
{name: "auth", kind: codersdk.ChatErrorKindAuth},
{name: "config", kind: codersdk.ChatErrorKindConfig},
{name: "generic", kind: codersdk.ChatErrorKindGeneric},
}
for _, tt := range tests {
@@ -309,7 +310,7 @@ func TestRecordStreamRetry(t *testing.T) {
requireCounter(t, reg, "coderd_chatd_stream_retries_total", 1, map[string]string{
"provider": "test-provider",
"model": "test-model",
"kind": tt.kind,
"kind": string(tt.kind),
})
})
}
@@ -558,14 +559,14 @@ func TestRun_StreamRetry_RecordsMetric(t *testing.T) {
// Back-compat: OnRetry still fires with classified error.
require.Len(t, retries, 1)
assert.Equal(t, 1, retries[0].attempt)
assert.Equal(t, chaterror.KindRateLimit, retries[0].classified.Kind)
assert.Equal(t, codersdk.ChatErrorKindRateLimit, retries[0].classified.Kind)
assert.Equal(t, "test-provider", retries[0].classified.Provider)
// Metric assertion.
requireCounter(t, reg, "coderd_chatd_stream_retries_total", 1, map[string]string{
"provider": "test-provider",
"model": "test-model",
"kind": chaterror.KindRateLimit,
"kind": string(codersdk.ChatErrorKindRateLimit),
})
}
+1 -2
View File
@@ -21,7 +21,6 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/pubsub"
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
"github.com/coder/coder/v2/coderd/x/chatd/chatloop"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
@@ -2719,7 +2718,7 @@ func setChatStatus(
if lastError != "" {
encodedLastError, err := json.Marshal(codersdk.ChatError{
Message: lastError,
Kind: chaterror.KindGeneric,
Kind: codersdk.ChatErrorKindGeneric,
})
require.NoError(t, err)
params.LastError = pqtype.NullRawMessage{RawMessage: encodedLastError, Valid: true}
+29 -2
View File
@@ -1442,6 +1442,33 @@ type ChatStreamStatus struct {
Status ChatStatus `json:"status"`
}
// ChatErrorKind classifies chat errors for consistent client rendering.
type ChatErrorKind string
const (
ChatErrorKindGeneric ChatErrorKind = "generic"
ChatErrorKindOverloaded ChatErrorKind = "overloaded"
ChatErrorKindRateLimit ChatErrorKind = "rate_limit"
ChatErrorKindTimeout ChatErrorKind = "timeout"
ChatErrorKindStartupTimeout ChatErrorKind = "startup_timeout"
ChatErrorKindAuth ChatErrorKind = "auth"
ChatErrorKindConfig ChatErrorKind = "config"
ChatErrorKindUsageLimit ChatErrorKind = "usage_limit"
)
// AllChatErrorKinds contains every ChatErrorKind value.
// Update this when adding new constants above.
var AllChatErrorKinds = []ChatErrorKind{
ChatErrorKindGeneric,
ChatErrorKindOverloaded,
ChatErrorKindRateLimit,
ChatErrorKindTimeout,
ChatErrorKindStartupTimeout,
ChatErrorKindAuth,
ChatErrorKindConfig,
ChatErrorKindUsageLimit,
}
// ChatError represents a terminal chat error in persisted chat state or the
// live stream.
type ChatError struct {
@@ -1451,7 +1478,7 @@ type ChatError struct {
// normalized error message when available.
Detail string `json:"detail,omitempty"`
// Kind classifies the error for consistent client rendering.
Kind string `json:"kind,omitempty"`
Kind ChatErrorKind `json:"kind,omitempty"`
// Provider identifies the upstream model provider when known.
Provider string `json:"provider,omitempty"`
// Retryable reports whether the underlying error is transient.
@@ -1470,7 +1497,7 @@ type ChatStreamRetry struct {
// Error is the normalized error message from the failed attempt.
Error string `json:"error"`
// Kind classifies the retry reason for consistent client rendering.
Kind string `json:"kind,omitempty"`
Kind ChatErrorKind `json:"kind,omitempty"`
// Provider identifies the upstream model provider when known.
Provider string `json:"provider,omitempty"`
// StatusCode is the best-effort upstream HTTP status code.
+30 -1
View File
@@ -137,6 +137,35 @@ func TestChatUsageLimitExceededFrom(t *testing.T) {
})
}
func TestChatErrorKind_JSONRoundTrip(t *testing.T) {
t.Parallel()
terminal := codersdk.ChatError{
Message: "limit reached",
Kind: codersdk.ChatErrorKindUsageLimit,
}
data, err := json.Marshal(terminal)
require.NoError(t, err)
require.Contains(t, string(data), `"kind":"usage_limit"`)
var decodedTerminal codersdk.ChatError
require.NoError(t, json.Unmarshal(data, &decodedTerminal))
require.Equal(t, codersdk.ChatErrorKindUsageLimit, decodedTerminal.Kind)
retry := codersdk.ChatStreamRetry{
Attempt: 1,
Error: "retrying",
Kind: codersdk.ChatErrorKindUsageLimit,
}
data, err = json.Marshal(retry)
require.NoError(t, err)
require.Contains(t, string(data), `"kind":"usage_limit"`)
var decodedRetry codersdk.ChatStreamRetry
require.NoError(t, json.Unmarshal(data, &decodedRetry))
require.Equal(t, codersdk.ChatErrorKindUsageLimit, decodedRetry.Kind)
}
func TestChatMessagePart_StripInternal(t *testing.T) {
t.Parallel()
@@ -450,7 +479,7 @@ func TestChat_JSONRoundTrip(t *testing.T) {
lastError := &codersdk.ChatError{
Message: "boom",
Detail: "provider detail",
Kind: "generic",
Kind: codersdk.ChatErrorKindGeneric,
Provider: "openai",
Retryable: true,
StatusCode: 503,
+1 -1
View File
@@ -587,7 +587,7 @@ func TestSubscribeRetryEventAcrossInstances(t *testing.T) {
require.NotNil(t, retryEvent)
require.Equal(t, 1, retryEvent.Attempt)
require.Greater(t, retryEvent.DelayMs, int64(0))
require.Equal(t, "rate_limit", retryEvent.Kind)
require.Equal(t, codersdk.ChatErrorKindRateLimit, retryEvent.Kind)
require.Equal(t, "openai", retryEvent.Provider)
require.Equal(t, 429, retryEvent.StatusCode)
require.Contains(t, retryEvent.Error, "rate limiting requests")
+24 -2
View File
@@ -1706,7 +1706,7 @@ export interface ChatError {
/**
* Kind classifies the error for consistent client rendering.
*/
readonly kind?: string;
readonly kind?: ChatErrorKind;
/**
* Provider identifies the upstream model provider when known.
*/
@@ -1721,6 +1721,28 @@ export interface ChatError {
readonly status_code?: number;
}
// From codersdk/chats.go
export type ChatErrorKind =
| "auth"
| "config"
| "generic"
| "overloaded"
| "rate_limit"
| "startup_timeout"
| "timeout"
| "usage_limit";
export const ChatErrorKinds: ChatErrorKind[] = [
"auth",
"config",
"generic",
"overloaded",
"rate_limit",
"startup_timeout",
"timeout",
"usage_limit",
];
// From codersdk/chats.go
/**
* ChatFileMetadata contains lightweight metadata about a file
@@ -2489,7 +2511,7 @@ export interface ChatStreamRetry {
/**
* Kind classifies the retry reason for consistent client rendering.
*/
readonly kind?: string;
readonly kind?: ChatErrorKind;
/**
* Provider identifies the upstream model provider when known.
*/
+2 -2
View File
@@ -93,7 +93,7 @@ import { parsePullRequestUrl } from "./utils/pullRequest";
import {
type ChatDetailError,
formatUsageLimitMessage,
isUsageLimitData,
isChatUsageLimitExceededResponse,
} from "./utils/usageLimitMessage";
/** localStorage key controlling whether the right panel is visible. */
@@ -1088,7 +1088,7 @@ const AgentChatPage: FC = () => {
if (
isApiError(error) &&
error.response?.status === 409 &&
isUsageLimitData(error.response.data)
isChatUsageLimitExceededResponse(error.response.data)
) {
const reason: ChatDetailError = {
kind: "usage_limit",
@@ -27,7 +27,7 @@ import {
} from "../utils/modelOptions";
import {
formatUsageLimitMessage,
isUsageLimitData,
isChatUsageLimitExceededResponse,
} from "../utils/usageLimitMessage";
import { AgentChatInput } from "./AgentChatInput";
import { ChatAccessDeniedAlert } from "./ChatAccessDeniedAlert";
@@ -464,7 +464,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
) : createError ? (
isApiError(createError) &&
createError.response?.status === 409 &&
isUsageLimitData(createError.response.data) ? (
isChatUsageLimitExceededResponse(createError.response.data) ? (
<Alert
severity="info"
actions={
@@ -15,7 +15,7 @@ export const normalizeChatErrorPayload = (
: undefined;
return {
message,
kind: error?.kind?.trim() || "generic",
kind: error?.kind ?? "generic",
provider: error?.provider?.trim() || undefined,
retryable: error?.retryable,
statusCode,
@@ -1,4 +1,4 @@
import type { ChatProviderFailureKind } from "../../utils/usageLimitMessage";
import type * as TypesGen from "#/api/typesGenerated";
const PROVIDER_STATUS_URLS: Record<string, string> = {
anthropic: "https://status.anthropic.com",
@@ -24,7 +24,7 @@ const normalizeProvider = (provider?: string): string | undefined => {
};
export const getErrorTitle = (
kind: ChatProviderFailureKind | (string & {}),
kind: TypesGen.ChatErrorKind,
mode: "retry" | "error",
): string => {
switch (kind) {
@@ -40,13 +40,15 @@ export const getErrorTitle = (
return "Authentication failed";
case "config":
return "Configuration error";
case "usage_limit":
return "Usage limit reached";
default:
return mode === "retry" ? "Retrying request" : "Request failed";
}
};
export const getProviderStatusURL = (
kind: ChatProviderFailureKind | (string & {}),
kind: TypesGen.ChatErrorKind,
provider?: string,
): string | undefined => {
if (kind !== "overloaded") {
@@ -1,3 +1,4 @@
import type * as TypesGen from "#/api/typesGenerated";
import type { ChatDetailError } from "../../utils/usageLimitMessage";
import { getErrorTitle } from "./chatStatusHelpers";
import type { ReconnectState, RetryState, StreamState } from "./types";
@@ -16,7 +17,7 @@ export type LiveStatusModel =
| ({
phase: "retrying";
title: string;
kind: string;
kind: TypesGen.ChatErrorKind;
message: string;
attempt: number;
provider?: string;
@@ -34,7 +35,7 @@ export type LiveStatusModel =
| ({
phase: "failed";
title: string;
kind: string;
kind: TypesGen.ChatErrorKind;
message: string;
detail?: string;
provider?: string;
@@ -1,6 +1,5 @@
import type * as TypesGen from "#/api/typesGenerated";
import type { ReconnectSchedule } from "#/utils/reconnectingWebSocket";
import type { ChatProviderFailureKind } from "../../utils/usageLimitMessage";
export type ParsedToolCall = {
id: string;
@@ -70,7 +69,7 @@ export type ReconnectState = ReconnectSchedule;
export type RetryState = {
attempt: number;
error: string;
kind: ChatProviderFailureKind | (string & {});
kind: TypesGen.ChatErrorKind;
provider?: string;
delayMs?: number;
retryingAt?: string;
@@ -26,7 +26,7 @@ import type { RetryState } from "./types";
const normalizeRetryState = (retry: TypesGen.ChatStreamRetry): RetryState => ({
attempt: Math.max(1, retry.attempt),
error: retry.error.trim() || "Retrying request shortly.",
kind: retry.kind?.trim() || "generic",
kind: retry.kind ?? "generic",
provider: retry.provider?.trim() || undefined,
delayMs: retry.delay_ms,
retryingAt: retry.retrying_at.trim() || undefined,
@@ -2,7 +2,7 @@ import {
type ChatDetailError,
chatDetailErrorsEqual,
formatUsageLimitMessage,
isUsageLimitData,
isChatUsageLimitExceededResponse,
} from "./usageLimitMessage";
describe("formatUsageLimitMessage", () => {
@@ -103,8 +103,8 @@ describe("chatDetailErrorsEqual", () => {
});
});
describe("isUsageLimitData", () => {
it("accepts a fully populated valid payload", () => {
describe("isChatUsageLimitExceededResponse", () => {
it("accepts a payload with usage fields", () => {
const error: ChatDetailError = {
message: "Your usage limit has been reached.",
kind: "usage_limit",
@@ -112,7 +112,7 @@ describe("isUsageLimitData", () => {
expect(error.kind).toBe("usage_limit");
expect(
isUsageLimitData({
isChatUsageLimitExceededResponse({
spent_micros: 900_000,
limit_micros: 500_000,
resets_at: "2026-03-16T00:00:00Z",
@@ -121,20 +121,21 @@ describe("isUsageLimitData", () => {
});
it("rejects null", () => {
expect(isUsageLimitData(null)).toBe(false);
expect(isChatUsageLimitExceededResponse(null)).toBe(false);
});
it("rejects undefined", () => {
expect(isUsageLimitData(undefined)).toBe(false);
expect(isChatUsageLimitExceededResponse(undefined)).toBe(false);
});
it("rejects an empty object (missing all fields)", () => {
expect(isUsageLimitData({})).toBe(false);
expect(isChatUsageLimitExceededResponse({})).toBe(false);
});
it("rejects when spent_micros is missing", () => {
expect(
isUsageLimitData({
isChatUsageLimitExceededResponse({
message: "Chat usage limit exceeded.",
limit_micros: 500_000,
resets_at: "2026-03-16T00:00:00Z",
}),
@@ -143,7 +144,8 @@ describe("isUsageLimitData", () => {
it("rejects when limit_micros is missing", () => {
expect(
isUsageLimitData({
isChatUsageLimitExceededResponse({
message: "Chat usage limit exceeded.",
spent_micros: 900_000,
resets_at: "2026-03-16T00:00:00Z",
}),
@@ -152,13 +154,18 @@ describe("isUsageLimitData", () => {
it("rejects when resets_at is missing", () => {
expect(
isUsageLimitData({ spent_micros: 900_000, limit_micros: 500_000 }),
isChatUsageLimitExceededResponse({
message: "Chat usage limit exceeded.",
spent_micros: 900_000,
limit_micros: 500_000,
}),
).toBe(false);
});
it("rejects wrong field types (string for spent_micros)", () => {
expect(
isUsageLimitData({
isChatUsageLimitExceededResponse({
message: "Chat usage limit exceeded.",
spent_micros: "900000",
limit_micros: 500_000,
resets_at: "2026-03-16T00:00:00Z",
@@ -168,7 +175,8 @@ describe("isUsageLimitData", () => {
it("accepts payload with extra fields", () => {
expect(
isUsageLimitData({
isChatUsageLimitExceededResponse({
message: "Chat usage limit exceeded.",
spent_micros: 900_000,
limit_micros: 500_000,
resets_at: "2026-03-16T00:00:00Z",
@@ -1,27 +1,12 @@
import type * as TypesGen from "#/api/typesGenerated";
import { formatCostMicros } from "#/utils/currency";
/**
* Shape of structured usage-limit fields added to 409 responses
* from chat endpoints.
*/
interface UsageLimitData {
spent_micros?: number;
limit_micros?: number;
resets_at?: string; // RFC3339
}
/**
* Known provider failure kinds surfaced in chat retry/error events.
*/
export type ChatProviderFailureKind =
| "generic"
| "overloaded"
| "rate_limit"
| "timeout"
| "startup_timeout"
| "auth"
| "config"
| "usage_limit";
type UsageLimitData = Partial<
Pick<
TypesGen.ChatUsageLimitExceededResponse,
"spent_micros" | "limit_micros" | "resets_at"
>
>;
/**
* Typed classification for errors surfaced in the agent detail view.
@@ -33,7 +18,7 @@ export type ChatProviderFailureKind =
export type ChatDetailError = {
message: string;
detail?: string;
kind: ChatProviderFailureKind | (string & {});
kind: TypesGen.ChatErrorKind;
provider?: string;
retryable?: boolean;
statusCode?: number;
@@ -81,11 +66,11 @@ function formatResetDate(isoString: string): string {
}
/**
* Runtime guard that validates whether an unknown value has the shape
* of structured usage-limit fields from a 409 response.
* All three fields must be present with correct types.
* Runtime guard for the structured 409 usage-limit response.
*/
export function isUsageLimitData(value: unknown): value is UsageLimitData {
export function isChatUsageLimitExceededResponse(
value: unknown,
): value is TypesGen.ChatUsageLimitExceededResponse {
if (value == null || typeof value !== "object") {
return false;
}