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}