Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 111 additions & 1 deletion src/api/providers/utils/__tests__/error-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ describe("handleProviderError", () => {
const result = handleProviderError(error, providerName)

expect(result).toBeInstanceOf(Error)
// Message should be prefixed with status code for UI parsing
expect(result.message).toMatch(/^401/)
expect(result.message).toContain("TestProvider completion error")
expect((result as any).status).toBe(401)
})
Expand Down Expand Up @@ -257,6 +259,111 @@ describe("handleProviderError", () => {
expect(result.message).toContain("Invalid API key")
})
})

describe("HTTP status code prefixing for UI parsing", () => {
it("should prefix error message with status code 402 (Payment Required)", () => {
const error = new Error("Insufficient balance") as any
error.status = 402

const result = handleProviderError(error, "DeepSeek")

// Message should start with status code for UI to parse
expect(result.message).toMatch(/^402/)
expect(result.message).toContain("DeepSeek completion error")
expect(result.message).toContain("Insufficient balance")
expect((result as any).status).toBe(402)
})

it("should prefix error message with status code 401 (Unauthorized)", () => {
const error = new Error("Invalid API key") as any
error.status = 401

const result = handleProviderError(error, "OpenAI")

expect(result.message).toMatch(/^401/)
expect(result.message).toContain("OpenAI completion error")
expect((result as any).status).toBe(401)
})

it("should prefix error message with status code 403 (Forbidden)", () => {
const error = new Error("Access denied") as any
error.status = 403

const result = handleProviderError(error, "Anthropic")

expect(result.message).toMatch(/^403/)
expect(result.message).toContain("Anthropic completion error")
expect((result as any).status).toBe(403)
})

it("should prefix error message with status code 429 (Rate Limit)", () => {
const error = new Error("Too many requests") as any
error.status = 429

const result = handleProviderError(error, "Mistral")

expect(result.message).toMatch(/^429/)
expect(result.message).toContain("Mistral completion error")
expect((result as any).status).toBe(429)
})

it("should prefix error message with status code 500 (Server Error)", () => {
const error = new Error("Internal server error") as any
error.status = 500

const result = handleProviderError(error, "Groq")

expect(result.message).toMatch(/^500/)
expect(result.message).toContain("Groq completion error")
expect((result as any).status).toBe(500)
})

it("should not prefix with status code when using custom messageTransformer", () => {
const error = new Error("Payment required") as any
error.status = 402

const result = handleProviderError(error, "TestProvider", {
messageTransformer: (msg) => `Custom: ${msg}`,
})

// Custom transformer should be used as-is
expect(result.message).toBe("Custom: Payment required")
// Status should still be preserved
expect((result as any).status).toBe(402)
})

it("should not prefix with status code for non-HTTP errors (no status)", () => {
const error = new Error("Network timeout")

const result = handleProviderError(error, "TestProvider")

// Should not have status prefix
expect(result.message).not.toMatch(/^\d{3}/)
expect(result.message).toBe("TestProvider completion error: Network timeout")
})

it("should not prefix with status code for status < 400", () => {
const error = new Error("Redirect") as any
error.status = 301

const result = handleProviderError(error, "TestProvider")

// Should not have status prefix for non-error status codes
expect(result.message).not.toMatch(/^301/)
expect(result.message).toBe("TestProvider completion error: Redirect")
})

it("should work with custom messagePrefix option", () => {
const error = new Error("No funds") as any
error.status = 402

const result = handleProviderError(error, "DeepSeek", { messagePrefix: "streaming" })

expect(result.message).toMatch(/^402/)
expect(result.message).toContain("DeepSeek streaming error")
expect(result.message).toContain("No funds")
})
})
})

describe("handleOpenAIError (backward compatibility)", () => {
Expand All @@ -267,6 +374,8 @@ describe("handleOpenAIError (backward compatibility)", () => {
const result = handleOpenAIError(error, "OpenAI")

expect(result).toBeInstanceOf(Error)
// Should be prefixed with status code for UI parsing
expect(result.message).toMatch(/^500/)
expect(result.message).toContain("OpenAI completion error")
expect((result as any).status).toBe(500)
})
Expand All @@ -277,7 +386,8 @@ describe("handleOpenAIError (backward compatibility)", () => {

const result = handleOpenAIError(error, "Roo Code Cloud")

expect(result.message).toBe("Roo Code Cloud completion error: Authentication failed")
// Message should now include status prefix for UI parsing
expect(result.message).toBe("401 - Roo Code Cloud completion error: Authentication failed")
expect((result as any).status).toBe(401)
})
})
22 changes: 16 additions & 6 deletions src/api/providers/utils/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,14 @@ export function handleProviderError(
if (error instanceof Error) {
const anyErr = error as any
const msg = anyErr?.error?.metadata?.raw || error.message || ""
const status = anyErr.status

// Log the original error details for debugging
console.error(`[${providerName}] API error:`, {
message: msg,
name: error.name,
stack: error.stack,
status: anyErr.status,
status,
})

let wrapped: Error
Expand All @@ -66,17 +67,26 @@ export function handleProviderError(
wrapped = new Error(i18n.t("common:errors.api.invalidKeyInvalidChars"))
} else {
// Apply custom transformer if provided, otherwise use default format
const finalMessage = options?.messageTransformer
? options.messageTransformer(msg)
: `${providerName} ${messagePrefix} error: ${msg}`
// IMPORTANT: When HTTP status is available, prefix the message with the status code
// This allows the UI (ChatRow.tsx) to parse the status and show user-friendly error messages
// for known status codes (e.g., 402 = insufficient balance, 429 = rate limit)
let finalMessage: string
if (options?.messageTransformer) {
finalMessage = options.messageTransformer(msg)
} else if (typeof status === "number" && status >= 400) {
// Prefix with status code for UI parsing (e.g., "402 - DeepSeek completion error: ...")
finalMessage = `${status} - ${providerName} ${messagePrefix} error: ${msg}`
} else {
finalMessage = `${providerName} ${messagePrefix} error: ${msg}`
}
wrapped = new Error(finalMessage)
}

// Preserve HTTP status and structured details for retry/backoff + UI
// These fields are used by Task.backoffAndAnnounce() and ChatRow/ErrorRow
// to provide status-aware error messages and handling
if (anyErr.status !== undefined) {
;(wrapped as any).status = anyErr.status
if (status !== undefined) {
;(wrapped as any).status = status
}
if (anyErr.errorDetails !== undefined) {
;(wrapped as any).errorDetails = anyErr.errorDetails
Expand Down
Loading