Skip to content

Commit 71cebd0

Browse files
brunobergherellipsis-dev[bot]daniel-lxs
authored andcommitted
ux: improve API error handling and visibility (RooCodeInc#10204)
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> Co-authored-by: Daniel <57051444+daniel-lxs@users.noreply.github.com>
1 parent 0c4c50c commit 71cebd0

27 files changed

Lines changed: 642 additions & 60 deletions

File tree

src/api/providers/anthropic.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { ApiStream } from "../transform/stream"
2020
import { getModelParams } from "../transform/model-params"
2121
import { filterNonAnthropicBlocks } from "../transform/anthropic-filter"
2222
import { resolveToolProtocol } from "../../utils/resolveToolProtocol"
23+
import { handleProviderError } from "./utils/error-handler"
2324

2425
import { BaseProvider } from "./base-provider"
2526
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"

src/api/providers/gemini.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { convertAnthropicMessageToGemini } from "../transform/gemini-format"
2525
import { t } from "i18next"
2626
import type { ApiStream, GroundingSource } from "../transform/stream"
2727
import { getModelParams } from "../transform/model-params"
28+
import { handleProviderError } from "./utils/error-handler"
2829

2930
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
3031
import { BaseProvider } from "./base-provider"

src/api/providers/mistral.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { ApiHandlerOptions } from "../../shared/api"
1515

1616
import { convertToMistralMessages } from "../transform/mistral-format"
1717
import { ApiStream } from "../transform/stream"
18+
import { handleProviderError } from "./utils/error-handler"
1819

1920
import { BaseProvider } from "./base-provider"
2021
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import { handleProviderError, handleOpenAIError } from "../error-handler"
2+
3+
describe("handleProviderError", () => {
4+
const providerName = "TestProvider"
5+
6+
describe("HTTP status preservation", () => {
7+
it("should preserve status code from Error with status field", () => {
8+
const error = new Error("API request failed") as any
9+
error.status = 401
10+
11+
const result = handleProviderError(error, providerName)
12+
13+
expect(result).toBeInstanceOf(Error)
14+
expect(result.message).toContain("TestProvider completion error")
15+
expect((result as any).status).toBe(401)
16+
})
17+
18+
it("should preserve status code from Error with nested error structure", () => {
19+
const error = new Error("Wrapped error") as any
20+
error.status = 429
21+
error.errorDetails = [{ "@type": "type.googleapis.com/google.rpc.RetryInfo" }]
22+
23+
const result = handleProviderError(error, providerName)
24+
25+
expect((result as any).status).toBe(429)
26+
expect((result as any).errorDetails).toBeDefined()
27+
})
28+
29+
it("should preserve status from non-Error exception", () => {
30+
const error = {
31+
status: 500,
32+
message: "Internal server error",
33+
}
34+
35+
const result = handleProviderError(error, providerName)
36+
37+
expect(result).toBeInstanceOf(Error)
38+
expect((result as any).status).toBe(500)
39+
})
40+
41+
it("should not add status field if original error lacks it", () => {
42+
const error = new Error("Generic error")
43+
44+
const result = handleProviderError(error, providerName)
45+
46+
expect(result).toBeInstanceOf(Error)
47+
expect((result as any).status).toBeUndefined()
48+
})
49+
})
50+
51+
describe("errorDetails preservation", () => {
52+
it("should preserve errorDetails array from original error", () => {
53+
const error = new Error("Rate limited") as any
54+
error.status = 429
55+
error.errorDetails = [{ "@type": "type.googleapis.com/google.rpc.RetryInfo", retryDelay: "5s" }]
56+
57+
const result = handleProviderError(error, providerName)
58+
59+
expect((result as any).errorDetails).toEqual(error.errorDetails)
60+
})
61+
62+
it("should preserve code field from original error", () => {
63+
const error = new Error("Bad request") as any
64+
error.code = "invalid_request"
65+
66+
const result = handleProviderError(error, providerName)
67+
68+
expect((result as any).code).toBe("invalid_request")
69+
})
70+
71+
it("should preserve AWS $metadata from original error", () => {
72+
const error = new Error("AWS error") as any
73+
error.$metadata = { httpStatusCode: 403, requestId: "test-123" }
74+
75+
const result = handleProviderError(error, providerName)
76+
77+
expect((result as any).$metadata).toEqual(error.$metadata)
78+
})
79+
})
80+
81+
describe("custom message prefix", () => {
82+
it("should use custom message prefix when provided", () => {
83+
const error = new Error("Stream failed")
84+
85+
const result = handleProviderError(error, providerName, { messagePrefix: "streaming" })
86+
87+
expect(result.message).toBe("TestProvider streaming error: Stream failed")
88+
})
89+
90+
it("should default to 'completion' prefix when not provided", () => {
91+
const error = new Error("Request failed")
92+
93+
const result = handleProviderError(error, providerName)
94+
95+
expect(result.message).toBe("TestProvider completion error: Request failed")
96+
})
97+
})
98+
99+
describe("custom message transformer", () => {
100+
it("should use custom message transformer when provided", () => {
101+
const error = new Error("API error")
102+
103+
const result = handleProviderError(error, providerName, {
104+
messageTransformer: (msg) => `Custom format: ${msg}`,
105+
})
106+
107+
expect(result.message).toBe("Custom format: API error")
108+
})
109+
110+
it("should preserve status even with custom transformer", () => {
111+
const error = new Error("Rate limited") as any
112+
error.status = 429
113+
114+
const result = handleProviderError(error, providerName, {
115+
messageTransformer: (msg) => `Transformed: ${msg}`,
116+
})
117+
118+
expect(result.message).toBe("Transformed: Rate limited")
119+
expect((result as any).status).toBe(429)
120+
})
121+
})
122+
123+
describe("ByteString conversion errors", () => {
124+
it("should return localized message for ByteString conversion errors", () => {
125+
const error = new Error("Cannot convert argument to a ByteString")
126+
127+
const result = handleProviderError(error, providerName)
128+
129+
expect(result.message).not.toContain("TestProvider completion error")
130+
// The actual translated message depends on i18n setup
131+
expect(result.message).toBeTruthy()
132+
})
133+
134+
it("should preserve status even for ByteString errors", () => {
135+
const error = new Error("Cannot convert argument to a ByteString") as any
136+
error.status = 400
137+
138+
const result = handleProviderError(error, providerName)
139+
140+
// Even though ByteString errors are typically client-side,
141+
// we preserve any status metadata that exists for debugging purposes
142+
expect((result as any).status).toBe(400)
143+
})
144+
})
145+
146+
describe("error message formatting", () => {
147+
it("should wrap error message with provider name prefix", () => {
148+
const error = new Error("Authentication failed")
149+
150+
const result = handleProviderError(error, providerName)
151+
152+
expect(result.message).toBe("TestProvider completion error: Authentication failed")
153+
})
154+
155+
it("should handle error with nested metadata", () => {
156+
const error = new Error("Network error") as any
157+
error.error = {
158+
metadata: {
159+
raw: "Connection refused",
160+
},
161+
}
162+
163+
const result = handleProviderError(error, providerName)
164+
165+
expect(result.message).toContain("Connection refused")
166+
expect(result.message).toContain("TestProvider completion error")
167+
})
168+
169+
it("should handle non-Error exceptions", () => {
170+
const error = { message: "Something went wrong" }
171+
172+
const result = handleProviderError(error, providerName)
173+
174+
expect(result).toBeInstanceOf(Error)
175+
expect(result.message).toContain("TestProvider completion error")
176+
expect(result.message).toContain("[object Object]")
177+
})
178+
179+
it("should handle string exceptions", () => {
180+
const error = "Connection timeout"
181+
182+
const result = handleProviderError(error, providerName)
183+
184+
expect(result).toBeInstanceOf(Error)
185+
expect(result.message).toBe("TestProvider completion error: Connection timeout")
186+
})
187+
})
188+
189+
describe("real-world error scenarios", () => {
190+
it("should handle 401 Unauthorized with status and message", () => {
191+
const error = new Error("Unauthorized") as any
192+
error.status = 401
193+
194+
const result = handleProviderError(error, providerName)
195+
196+
expect(result.message).toContain("Unauthorized")
197+
expect((result as any).status).toBe(401)
198+
})
199+
200+
it("should handle 429 Rate Limit with RetryInfo", () => {
201+
const error = new Error("Rate limit exceeded") as any
202+
error.status = 429
203+
error.errorDetails = [
204+
{
205+
"@type": "type.googleapis.com/google.rpc.RetryInfo",
206+
retryDelay: "10s",
207+
},
208+
]
209+
210+
const result = handleProviderError(error, providerName)
211+
212+
expect((result as any).status).toBe(429)
213+
expect((result as any).errorDetails).toBeDefined()
214+
expect((result as any).errorDetails[0].retryDelay).toBe("10s")
215+
})
216+
217+
it("should handle 500 Internal Server Error", () => {
218+
const error = new Error("Internal server error") as any
219+
error.status = 500
220+
221+
const result = handleProviderError(error, providerName)
222+
223+
expect((result as any).status).toBe(500)
224+
expect(result.message).toContain("Internal server error")
225+
})
226+
227+
it("should handle errors without status gracefully", () => {
228+
const error = new Error("Network connectivity issue")
229+
230+
const result = handleProviderError(error, providerName)
231+
232+
expect(result).toBeInstanceOf(Error)
233+
expect((result as any).status).toBeUndefined()
234+
expect(result.message).toContain("Network connectivity issue")
235+
})
236+
237+
it("should handle Gemini-specific errors with custom transformer", () => {
238+
const error = new Error("Model not found") as any
239+
error.status = 404
240+
241+
const result = handleProviderError(error, "Gemini", {
242+
messageTransformer: (msg) => `Gemini API Error: ${msg}`,
243+
})
244+
245+
expect(result.message).toBe("Gemini API Error: Model not found")
246+
expect((result as any).status).toBe(404)
247+
})
248+
249+
it("should handle Anthropic SDK errors", () => {
250+
const error = new Error("Invalid API key") as any
251+
error.status = 401
252+
error.error = { type: "authentication_error" }
253+
254+
const result = handleProviderError(error, "Anthropic")
255+
256+
expect((result as any).status).toBe(401)
257+
expect(result.message).toContain("Invalid API key")
258+
})
259+
})
260+
})
261+
262+
describe("handleOpenAIError (backward compatibility)", () => {
263+
it("should be an alias for handleProviderError with completion prefix", () => {
264+
const error = new Error("API failed") as any
265+
error.status = 500
266+
267+
const result = handleOpenAIError(error, "OpenAI")
268+
269+
expect(result).toBeInstanceOf(Error)
270+
expect(result.message).toContain("OpenAI completion error")
271+
expect((result as any).status).toBe(500)
272+
})
273+
274+
it("should preserve backward compatibility for existing callers", () => {
275+
const error = new Error("Authentication failed") as any
276+
error.status = 401
277+
278+
const result = handleOpenAIError(error, "Roo Code Cloud")
279+
280+
expect(result.message).toBe("Roo Code Cloud completion error: Authentication failed")
281+
expect((result as any).status).toBe(401)
282+
})
283+
})

0 commit comments

Comments
 (0)