mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +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}
|
||||
|
||||
+29
-2
@@ -1442,6 +1442,33 @@ type ChatStreamStatus struct {
|
||||
Status ChatStatus `json:"status"`
|
||||
}
|
||||
|
||||
// ChatErrorKind classifies chat errors for consistent client rendering.
|
||||
type ChatErrorKind string
|
||||
|
||||
const (
|
||||
ChatErrorKindGeneric ChatErrorKind = "generic"
|
||||
ChatErrorKindOverloaded ChatErrorKind = "overloaded"
|
||||
ChatErrorKindRateLimit ChatErrorKind = "rate_limit"
|
||||
ChatErrorKindTimeout ChatErrorKind = "timeout"
|
||||
ChatErrorKindStartupTimeout ChatErrorKind = "startup_timeout"
|
||||
ChatErrorKindAuth ChatErrorKind = "auth"
|
||||
ChatErrorKindConfig ChatErrorKind = "config"
|
||||
ChatErrorKindUsageLimit ChatErrorKind = "usage_limit"
|
||||
)
|
||||
|
||||
// AllChatErrorKinds contains every ChatErrorKind value.
|
||||
// Update this when adding new constants above.
|
||||
var AllChatErrorKinds = []ChatErrorKind{
|
||||
ChatErrorKindGeneric,
|
||||
ChatErrorKindOverloaded,
|
||||
ChatErrorKindRateLimit,
|
||||
ChatErrorKindTimeout,
|
||||
ChatErrorKindStartupTimeout,
|
||||
ChatErrorKindAuth,
|
||||
ChatErrorKindConfig,
|
||||
ChatErrorKindUsageLimit,
|
||||
}
|
||||
|
||||
// ChatError represents a terminal chat error in persisted chat state or the
|
||||
// live stream.
|
||||
type ChatError struct {
|
||||
@@ -1451,7 +1478,7 @@ type ChatError struct {
|
||||
// normalized error message when available.
|
||||
Detail string `json:"detail,omitempty"`
|
||||
// Kind classifies the error for consistent client rendering.
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Kind ChatErrorKind `json:"kind,omitempty"`
|
||||
// Provider identifies the upstream model provider when known.
|
||||
Provider string `json:"provider,omitempty"`
|
||||
// Retryable reports whether the underlying error is transient.
|
||||
@@ -1470,7 +1497,7 @@ type ChatStreamRetry struct {
|
||||
// Error is the normalized error message from the failed attempt.
|
||||
Error string `json:"error"`
|
||||
// Kind classifies the retry reason for consistent client rendering.
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Kind ChatErrorKind `json:"kind,omitempty"`
|
||||
// Provider identifies the upstream model provider when known.
|
||||
Provider string `json:"provider,omitempty"`
|
||||
// StatusCode is the best-effort upstream HTTP status code.
|
||||
|
||||
+30
-1
@@ -137,6 +137,35 @@ func TestChatUsageLimitExceededFrom(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatErrorKind_JSONRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
terminal := codersdk.ChatError{
|
||||
Message: "limit reached",
|
||||
Kind: codersdk.ChatErrorKindUsageLimit,
|
||||
}
|
||||
data, err := json.Marshal(terminal)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(data), `"kind":"usage_limit"`)
|
||||
|
||||
var decodedTerminal codersdk.ChatError
|
||||
require.NoError(t, json.Unmarshal(data, &decodedTerminal))
|
||||
require.Equal(t, codersdk.ChatErrorKindUsageLimit, decodedTerminal.Kind)
|
||||
|
||||
retry := codersdk.ChatStreamRetry{
|
||||
Attempt: 1,
|
||||
Error: "retrying",
|
||||
Kind: codersdk.ChatErrorKindUsageLimit,
|
||||
}
|
||||
data, err = json.Marshal(retry)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(data), `"kind":"usage_limit"`)
|
||||
|
||||
var decodedRetry codersdk.ChatStreamRetry
|
||||
require.NoError(t, json.Unmarshal(data, &decodedRetry))
|
||||
require.Equal(t, codersdk.ChatErrorKindUsageLimit, decodedRetry.Kind)
|
||||
}
|
||||
|
||||
func TestChatMessagePart_StripInternal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -450,7 +479,7 @@ func TestChat_JSONRoundTrip(t *testing.T) {
|
||||
lastError := &codersdk.ChatError{
|
||||
Message: "boom",
|
||||
Detail: "provider detail",
|
||||
Kind: "generic",
|
||||
Kind: codersdk.ChatErrorKindGeneric,
|
||||
Provider: "openai",
|
||||
Retryable: true,
|
||||
StatusCode: 503,
|
||||
|
||||
@@ -587,7 +587,7 @@ func TestSubscribeRetryEventAcrossInstances(t *testing.T) {
|
||||
require.NotNil(t, retryEvent)
|
||||
require.Equal(t, 1, retryEvent.Attempt)
|
||||
require.Greater(t, retryEvent.DelayMs, int64(0))
|
||||
require.Equal(t, "rate_limit", retryEvent.Kind)
|
||||
require.Equal(t, codersdk.ChatErrorKindRateLimit, retryEvent.Kind)
|
||||
require.Equal(t, "openai", retryEvent.Provider)
|
||||
require.Equal(t, 429, retryEvent.StatusCode)
|
||||
require.Contains(t, retryEvent.Error, "rate limiting requests")
|
||||
|
||||
Generated
+24
-2
@@ -1706,7 +1706,7 @@ export interface ChatError {
|
||||
/**
|
||||
* Kind classifies the error for consistent client rendering.
|
||||
*/
|
||||
readonly kind?: string;
|
||||
readonly kind?: ChatErrorKind;
|
||||
/**
|
||||
* Provider identifies the upstream model provider when known.
|
||||
*/
|
||||
@@ -1721,6 +1721,28 @@ export interface ChatError {
|
||||
readonly status_code?: number;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
export type ChatErrorKind =
|
||||
| "auth"
|
||||
| "config"
|
||||
| "generic"
|
||||
| "overloaded"
|
||||
| "rate_limit"
|
||||
| "startup_timeout"
|
||||
| "timeout"
|
||||
| "usage_limit";
|
||||
|
||||
export const ChatErrorKinds: ChatErrorKind[] = [
|
||||
"auth",
|
||||
"config",
|
||||
"generic",
|
||||
"overloaded",
|
||||
"rate_limit",
|
||||
"startup_timeout",
|
||||
"timeout",
|
||||
"usage_limit",
|
||||
];
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* ChatFileMetadata contains lightweight metadata about a file
|
||||
@@ -2489,7 +2511,7 @@ export interface ChatStreamRetry {
|
||||
/**
|
||||
* Kind classifies the retry reason for consistent client rendering.
|
||||
*/
|
||||
readonly kind?: string;
|
||||
readonly kind?: ChatErrorKind;
|
||||
/**
|
||||
* Provider identifies the upstream model provider when known.
|
||||
*/
|
||||
|
||||
@@ -93,7 +93,7 @@ import { parsePullRequestUrl } from "./utils/pullRequest";
|
||||
import {
|
||||
type ChatDetailError,
|
||||
formatUsageLimitMessage,
|
||||
isUsageLimitData,
|
||||
isChatUsageLimitExceededResponse,
|
||||
} from "./utils/usageLimitMessage";
|
||||
|
||||
/** localStorage key controlling whether the right panel is visible. */
|
||||
@@ -1088,7 +1088,7 @@ const AgentChatPage: FC = () => {
|
||||
if (
|
||||
isApiError(error) &&
|
||||
error.response?.status === 409 &&
|
||||
isUsageLimitData(error.response.data)
|
||||
isChatUsageLimitExceededResponse(error.response.data)
|
||||
) {
|
||||
const reason: ChatDetailError = {
|
||||
kind: "usage_limit",
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "../utils/modelOptions";
|
||||
import {
|
||||
formatUsageLimitMessage,
|
||||
isUsageLimitData,
|
||||
isChatUsageLimitExceededResponse,
|
||||
} from "../utils/usageLimitMessage";
|
||||
import { AgentChatInput } from "./AgentChatInput";
|
||||
import { ChatAccessDeniedAlert } from "./ChatAccessDeniedAlert";
|
||||
@@ -464,7 +464,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
||||
) : createError ? (
|
||||
isApiError(createError) &&
|
||||
createError.response?.status === 409 &&
|
||||
isUsageLimitData(createError.response.data) ? (
|
||||
isChatUsageLimitExceededResponse(createError.response.data) ? (
|
||||
<Alert
|
||||
severity="info"
|
||||
actions={
|
||||
|
||||
@@ -15,7 +15,7 @@ export const normalizeChatErrorPayload = (
|
||||
: undefined;
|
||||
return {
|
||||
message,
|
||||
kind: error?.kind?.trim() || "generic",
|
||||
kind: error?.kind ?? "generic",
|
||||
provider: error?.provider?.trim() || undefined,
|
||||
retryable: error?.retryable,
|
||||
statusCode,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChatProviderFailureKind } from "../../utils/usageLimitMessage";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
|
||||
const PROVIDER_STATUS_URLS: Record<string, string> = {
|
||||
anthropic: "https://status.anthropic.com",
|
||||
@@ -24,7 +24,7 @@ const normalizeProvider = (provider?: string): string | undefined => {
|
||||
};
|
||||
|
||||
export const getErrorTitle = (
|
||||
kind: ChatProviderFailureKind | (string & {}),
|
||||
kind: TypesGen.ChatErrorKind,
|
||||
mode: "retry" | "error",
|
||||
): string => {
|
||||
switch (kind) {
|
||||
@@ -40,13 +40,15 @@ export const getErrorTitle = (
|
||||
return "Authentication failed";
|
||||
case "config":
|
||||
return "Configuration error";
|
||||
case "usage_limit":
|
||||
return "Usage limit reached";
|
||||
default:
|
||||
return mode === "retry" ? "Retrying request" : "Request failed";
|
||||
}
|
||||
};
|
||||
|
||||
export const getProviderStatusURL = (
|
||||
kind: ChatProviderFailureKind | (string & {}),
|
||||
kind: TypesGen.ChatErrorKind,
|
||||
provider?: string,
|
||||
): string | undefined => {
|
||||
if (kind !== "overloaded") {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import type { ChatDetailError } from "../../utils/usageLimitMessage";
|
||||
import { getErrorTitle } from "./chatStatusHelpers";
|
||||
import type { ReconnectState, RetryState, StreamState } from "./types";
|
||||
@@ -16,7 +17,7 @@ export type LiveStatusModel =
|
||||
| ({
|
||||
phase: "retrying";
|
||||
title: string;
|
||||
kind: string;
|
||||
kind: TypesGen.ChatErrorKind;
|
||||
message: string;
|
||||
attempt: number;
|
||||
provider?: string;
|
||||
@@ -34,7 +35,7 @@ export type LiveStatusModel =
|
||||
| ({
|
||||
phase: "failed";
|
||||
title: string;
|
||||
kind: string;
|
||||
kind: TypesGen.ChatErrorKind;
|
||||
message: string;
|
||||
detail?: string;
|
||||
provider?: string;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import type { ReconnectSchedule } from "#/utils/reconnectingWebSocket";
|
||||
import type { ChatProviderFailureKind } from "../../utils/usageLimitMessage";
|
||||
|
||||
export type ParsedToolCall = {
|
||||
id: string;
|
||||
@@ -70,7 +69,7 @@ export type ReconnectState = ReconnectSchedule;
|
||||
export type RetryState = {
|
||||
attempt: number;
|
||||
error: string;
|
||||
kind: ChatProviderFailureKind | (string & {});
|
||||
kind: TypesGen.ChatErrorKind;
|
||||
provider?: string;
|
||||
delayMs?: number;
|
||||
retryingAt?: string;
|
||||
|
||||
@@ -26,7 +26,7 @@ import type { RetryState } from "./types";
|
||||
const normalizeRetryState = (retry: TypesGen.ChatStreamRetry): RetryState => ({
|
||||
attempt: Math.max(1, retry.attempt),
|
||||
error: retry.error.trim() || "Retrying request shortly.",
|
||||
kind: retry.kind?.trim() || "generic",
|
||||
kind: retry.kind ?? "generic",
|
||||
provider: retry.provider?.trim() || undefined,
|
||||
delayMs: retry.delay_ms,
|
||||
retryingAt: retry.retrying_at.trim() || undefined,
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
type ChatDetailError,
|
||||
chatDetailErrorsEqual,
|
||||
formatUsageLimitMessage,
|
||||
isUsageLimitData,
|
||||
isChatUsageLimitExceededResponse,
|
||||
} from "./usageLimitMessage";
|
||||
|
||||
describe("formatUsageLimitMessage", () => {
|
||||
@@ -103,8 +103,8 @@ describe("chatDetailErrorsEqual", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUsageLimitData", () => {
|
||||
it("accepts a fully populated valid payload", () => {
|
||||
describe("isChatUsageLimitExceededResponse", () => {
|
||||
it("accepts a payload with usage fields", () => {
|
||||
const error: ChatDetailError = {
|
||||
message: "Your usage limit has been reached.",
|
||||
kind: "usage_limit",
|
||||
@@ -112,7 +112,7 @@ describe("isUsageLimitData", () => {
|
||||
|
||||
expect(error.kind).toBe("usage_limit");
|
||||
expect(
|
||||
isUsageLimitData({
|
||||
isChatUsageLimitExceededResponse({
|
||||
spent_micros: 900_000,
|
||||
limit_micros: 500_000,
|
||||
resets_at: "2026-03-16T00:00:00Z",
|
||||
@@ -121,20 +121,21 @@ describe("isUsageLimitData", () => {
|
||||
});
|
||||
|
||||
it("rejects null", () => {
|
||||
expect(isUsageLimitData(null)).toBe(false);
|
||||
expect(isChatUsageLimitExceededResponse(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects undefined", () => {
|
||||
expect(isUsageLimitData(undefined)).toBe(false);
|
||||
expect(isChatUsageLimitExceededResponse(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty object (missing all fields)", () => {
|
||||
expect(isUsageLimitData({})).toBe(false);
|
||||
expect(isChatUsageLimitExceededResponse({})).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects when spent_micros is missing", () => {
|
||||
expect(
|
||||
isUsageLimitData({
|
||||
isChatUsageLimitExceededResponse({
|
||||
message: "Chat usage limit exceeded.",
|
||||
limit_micros: 500_000,
|
||||
resets_at: "2026-03-16T00:00:00Z",
|
||||
}),
|
||||
@@ -143,7 +144,8 @@ describe("isUsageLimitData", () => {
|
||||
|
||||
it("rejects when limit_micros is missing", () => {
|
||||
expect(
|
||||
isUsageLimitData({
|
||||
isChatUsageLimitExceededResponse({
|
||||
message: "Chat usage limit exceeded.",
|
||||
spent_micros: 900_000,
|
||||
resets_at: "2026-03-16T00:00:00Z",
|
||||
}),
|
||||
@@ -152,13 +154,18 @@ describe("isUsageLimitData", () => {
|
||||
|
||||
it("rejects when resets_at is missing", () => {
|
||||
expect(
|
||||
isUsageLimitData({ spent_micros: 900_000, limit_micros: 500_000 }),
|
||||
isChatUsageLimitExceededResponse({
|
||||
message: "Chat usage limit exceeded.",
|
||||
spent_micros: 900_000,
|
||||
limit_micros: 500_000,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects wrong field types (string for spent_micros)", () => {
|
||||
expect(
|
||||
isUsageLimitData({
|
||||
isChatUsageLimitExceededResponse({
|
||||
message: "Chat usage limit exceeded.",
|
||||
spent_micros: "900000",
|
||||
limit_micros: 500_000,
|
||||
resets_at: "2026-03-16T00:00:00Z",
|
||||
@@ -168,7 +175,8 @@ describe("isUsageLimitData", () => {
|
||||
|
||||
it("accepts payload with extra fields", () => {
|
||||
expect(
|
||||
isUsageLimitData({
|
||||
isChatUsageLimitExceededResponse({
|
||||
message: "Chat usage limit exceeded.",
|
||||
spent_micros: 900_000,
|
||||
limit_micros: 500_000,
|
||||
resets_at: "2026-03-16T00:00:00Z",
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { formatCostMicros } from "#/utils/currency";
|
||||
|
||||
/**
|
||||
* Shape of structured usage-limit fields added to 409 responses
|
||||
* from chat endpoints.
|
||||
*/
|
||||
interface UsageLimitData {
|
||||
spent_micros?: number;
|
||||
limit_micros?: number;
|
||||
resets_at?: string; // RFC3339
|
||||
}
|
||||
|
||||
/**
|
||||
* Known provider failure kinds surfaced in chat retry/error events.
|
||||
*/
|
||||
export type ChatProviderFailureKind =
|
||||
| "generic"
|
||||
| "overloaded"
|
||||
| "rate_limit"
|
||||
| "timeout"
|
||||
| "startup_timeout"
|
||||
| "auth"
|
||||
| "config"
|
||||
| "usage_limit";
|
||||
type UsageLimitData = Partial<
|
||||
Pick<
|
||||
TypesGen.ChatUsageLimitExceededResponse,
|
||||
"spent_micros" | "limit_micros" | "resets_at"
|
||||
>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Typed classification for errors surfaced in the agent detail view.
|
||||
@@ -33,7 +18,7 @@ export type ChatProviderFailureKind =
|
||||
export type ChatDetailError = {
|
||||
message: string;
|
||||
detail?: string;
|
||||
kind: ChatProviderFailureKind | (string & {});
|
||||
kind: TypesGen.ChatErrorKind;
|
||||
provider?: string;
|
||||
retryable?: boolean;
|
||||
statusCode?: number;
|
||||
@@ -81,11 +66,11 @@ function formatResetDate(isoString: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime guard that validates whether an unknown value has the shape
|
||||
* of structured usage-limit fields from a 409 response.
|
||||
* All three fields must be present with correct types.
|
||||
* Runtime guard for the structured 409 usage-limit response.
|
||||
*/
|
||||
export function isUsageLimitData(value: unknown): value is UsageLimitData {
|
||||
export function isChatUsageLimitExceededResponse(
|
||||
value: unknown,
|
||||
): value is TypesGen.ChatUsageLimitExceededResponse {
|
||||
if (value == null || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user