mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -710,7 +710,7 @@ func classifyStartupTimeout(
|
||||
err = errStartupTimeout
|
||||
}
|
||||
return chaterror.WithClassification(err, chaterror.ClassifiedError{
|
||||
Kind: chaterror.KindStartupTimeout,
|
||||
Kind: codersdk.ChatErrorKindStartupTimeout,
|
||||
Provider: provider,
|
||||
Retryable: true,
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user