feat: improve HTTP 402 error handling for all providers

Fixes issue where providers like DeepSeek show "Unknown API error"
instead of user-friendly messages for HTTP status codes like 402
(insufficient balance/payment required).

Changes:
- Modify handleProviderError to prefix error messages with HTTP status
  code (e.g., "402 - DeepSeek completion error: ...") when status >= 400
- This allows the UI (ChatRow.tsx) to parse the status code and display
  appropriate user-friendly messages from the localization files
- Preserves existing behavior for custom messageTransformers and
  non-HTTP errors

The UI already has localized messages for common HTTP status codes
(400, 401, 402, 403, 429, 500) but they were not being shown because
the error message format did not allow the UI to extract the status code.

Closes #10016
This commit is contained in:
Roo Code 2026-01-07 21:00:20 +00:00
parent d7d3f4099b
commit e91bb8593d
2 changed files with 127 additions and 7 deletions

View file

@ -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)
})
@ -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)", () => {
@ -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)
})
@ -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)
})
})

View file

@ -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
@ -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