Skip to content

Commit 3d21dca

Browse files
committed
Implementing Consistent Error Handling Across Providers
This document provides instructions for implementing consistent error handling across all provider classes in the `src/api/providers/` directory. The goal is to ensure that all providers format errors in a consistent way that can be properly processed by `Cline.ts`. ## Background The `Cline.ts` component expects errors to be formatted in a specific way to properly handle different error types, especially rate limit errors (429). Currently, only the `vscode-lm.ts` and `anthropic.ts` providers format errors in this way. We need to update all other providers to follow the same pattern. ## Error Format Errors should be formatted as JSON strings wrapped in Error objects with the following structure: ```javascript throw new Error(JSON.stringify({ status: 429, // HTTP status code message: "Rate limit exceeded", // Human-readable message error: { metadata: { raw: errorObj.message || "Too many requests, please try again later" } }, errorDetails: [{ // Optional, for rate limit errors "@type": "type.googleapis.com/google.rpc.RetryInfo", retryDelay: "30s" }] })); ``` ## Implementation Steps For each provider in the `src/api/providers/` directory, follow these steps: 1. **Identify the `createMessage` Method**: - Locate the `createMessage` method in the provider class. - This method should be an async generator function that returns an `ApiStream`. 2. **Add Error Handling**: - Wrap the main logic of the method in a try-catch block. - In the catch block, implement error handling as shown below. 3. **Format Errors Consistently**: - Add specific handling for different error types: - Rate limit errors (429) - Authentication errors (401) - Bad request errors (400) - Other errors (500) - Return errors as JSON strings in Error objects. 4. **Update Tests**: - Update the tests for each provider to verify the new error handling. - Add tests for different error scenarios. ## Error Handling Template Here's a template for the error handling code to add to each provider: ```typescript try { // Existing code for creating and processing the stream } catch (error) { // Format errors in a consistent way console.error("Provider API error:", error); // Handle rate limit errors specifically const errorObj = error as any; if (errorObj.status === 429 || (errorObj.message && errorObj.message.toLowerCase().includes('rate limit'))) { throw new Error(JSON.stringify({ status: 429, message: "Rate limit exceeded", error: { metadata: { raw: errorObj.message || "Too many requests, please try again later" } }, errorDetails: [{ "@type": "type.googleapis.com/google.rpc.RetryInfo", retryDelay: "30s" // Default retry delay if not provided }] })); } // Handle authentication errors if (errorObj.status === 401 || (errorObj.message && errorObj.message.toLowerCase().includes('api key'))) { throw new Error(JSON.stringify({ status: 401, message: "Authentication error", error: { metadata: { raw: errorObj.message || "Invalid API key or unauthorized access" } } })); } // Handle bad request errors if (errorObj.status === 400 || (errorObj.error && errorObj.error.type === "invalid_request_error")) { throw new Error(JSON.stringify({ status: 400, message: "Bad request", error: { metadata: { raw: errorObj.message || "Invalid request parameters", param: errorObj.error?.param } } })); } // Handle other errors if (error instanceof Error) { throw new Error(JSON.stringify({ status: errorObj.status || 500, message: error.message, error: { metadata: { raw: error.message } } })); } else if (typeof error === "object" && error !== null) { const errorDetails = JSON.stringify(error, null, 2); throw new Error(JSON.stringify({ status: errorObj.status || 500, message: errorObj.message || errorDetails, error: { metadata: { raw: errorDetails } } })); } else { // Handle primitive errors or other unexpected types throw new Error(JSON.stringify({ status: 500, message: String(error), error: { metadata: { raw: String(error) } } })); } } ``` ## Provider-Specific Considerations Some providers may have specific error types or patterns that need special handling. Here are some provider-specific considerations: ### OpenAI and OpenAI-Native - Check for OpenAI-specific error types like `openai.APIError`. - Look for rate limit indicators in the error response. ### Bedrock - Bedrock may have AWS-specific error types. - Look for throttling indicators in the error message. ### Vertex - Vertex may have Google Cloud-specific error types. - Check for quota exceeded errors. ### Gemini - Similar to Vertex, check for Google-specific error patterns. ### Mistral, DeepSeek, Ollama, LMStudio - These providers may have their own error formats. - Adapt the error handling to match their specific patterns. ## Testing For each provider, update or add tests to verify the error handling: 1. **Test Rate Limit Errors**: - Mock a 429 status code error. - Verify the error is formatted correctly. 2. **Test Authentication Errors**: - Mock a 401 status code error. - Verify the error is formatted correctly. 3. **Test Bad Request Errors**: - Mock a 400 status code error. - Verify the error is formatted correctly. 4. **Test Other Errors**: - Mock a generic error. - Verify the error is formatted correctly. ## Example Test Here's an example test for verifying rate limit error handling: ```typescript it("should handle rate limit errors with proper format", async () => { mockCreate.mockImplementationOnce(() => { const error = new Error("Rate limit exceeded"); error.status = 429; throw error; }); const stream = handler.createMessage(systemPrompt, messages); try { for await (const _ of stream) { // consume stream } fail("Should have thrown an error"); } catch (error) { expect(error).toBeInstanceOf(Error); const parsedError = JSON.parse((error as Error).message); expect(parsedError.status).toBe(429); expect(parsedError.message).toBe("Rate limit exceeded"); expect(parsedError.errorDetails[0]["@type"]).toBe("type.googleapis.com/google.rpc.RetryInfo"); } }); ``` ## Providers to Update Here's a list of all providers that need to be updated: 1. ✅ `anthropic.ts` (already updated) 2. ✅ `vscode-lm.ts` (already updated) 3. ✅ `openai.ts` (already updated) 4. ✅ `openai-native.ts` (already updated) 5. ✅ `bedrock.ts` (already updated) 6. ✅ `vertex.ts`(already updated) 7. ✅ `gemini.ts`(already updated) 8. ✅ `mistral.ts` (already updated) 9. ✅ `deepseek.ts` (already updated) 10. ✅ `ollama.ts` (already updated) 11. ✅ `lmstudio.ts` (already updated) 12. ✅ `openrouter.ts` (already updated) 13. ✅ `glama.ts` 14. ✅ `unbound.ts` 15. ✅ `requesty.ts` 16. ✅ `human-relay.ts` 17. ✅ `fake-ai.ts` ## Conclusion By implementing consistent error handling across all providers, we ensure that `Cline.ts` can properly handle errors from any provider, especially rate limit errors. This will improve the user experience by providing better error messages and enabling automatic retries when appropriate.
1 parent 5bae398 commit 3d21dca

26 files changed

+4480
-673
lines changed
Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
// npx jest src/api/providers/__tests__/anthropic.createMessage.test.ts
2+
3+
import { AnthropicHandler } from "../anthropic"
4+
import { ApiHandlerOptions } from "../../../shared/api"
5+
import { Anthropic } from "@anthropic-ai/sdk"
6+
import { ApiStreamChunk } from "../../transform/stream"
7+
import { fail } from "assert"
8+
9+
// Define custom error type for tests
10+
interface ApiError extends Error {
11+
status?: number
12+
}
13+
14+
// Mock Anthropic SDK
15+
jest.mock("@anthropic-ai/sdk", () => {
16+
return {
17+
Anthropic: jest.fn().mockImplementation(() => ({
18+
messages: {
19+
create: jest.fn(),
20+
},
21+
})),
22+
}
23+
})
24+
25+
describe("AnthropicHandler.createMessage", () => {
26+
let handler: AnthropicHandler
27+
let mockCreate: jest.Mock
28+
const mockOptions: ApiHandlerOptions = {
29+
apiKey: "test-api-key",
30+
apiModelId: "claude-3-5-sonnet-20241022",
31+
}
32+
const systemPrompt = "You are a helpful assistant."
33+
const messages: Anthropic.Messages.MessageParam[] = [
34+
{
35+
role: "user",
36+
content: [{ type: "text" as const, text: "Hello" }],
37+
},
38+
]
39+
40+
beforeEach(() => {
41+
jest.clearAllMocks()
42+
handler = new AnthropicHandler(mockOptions)
43+
mockCreate = handler["client"].messages.create as jest.Mock
44+
45+
// Default mock implementation for successful streaming
46+
mockCreate.mockResolvedValue({
47+
async *[Symbol.asyncIterator]() {
48+
yield {
49+
type: "message_start",
50+
message: {
51+
usage: {
52+
input_tokens: 100,
53+
output_tokens: 50,
54+
cache_creation_input_tokens: 20,
55+
cache_read_input_tokens: 10,
56+
},
57+
},
58+
}
59+
yield {
60+
type: "content_block_start",
61+
index: 0,
62+
content_block: {
63+
type: "text",
64+
text: "Hello",
65+
},
66+
}
67+
yield {
68+
type: "content_block_delta",
69+
delta: {
70+
type: "text_delta",
71+
text: " world",
72+
},
73+
}
74+
},
75+
})
76+
})
77+
78+
it("should handle successful streaming", async () => {
79+
const stream = handler.createMessage(systemPrompt, messages)
80+
const chunks: ApiStreamChunk[] = []
81+
82+
for await (const chunk of stream) {
83+
chunks.push(chunk)
84+
}
85+
86+
expect(chunks.length).toBeGreaterThan(0)
87+
expect(chunks[0]).toEqual({
88+
type: "usage",
89+
inputTokens: 100,
90+
outputTokens: 50,
91+
cacheWriteTokens: 20,
92+
cacheReadTokens: 10,
93+
})
94+
expect(chunks[1]).toEqual({
95+
type: "text",
96+
text: "Hello",
97+
})
98+
expect(chunks[2]).toEqual({
99+
type: "text",
100+
text: " world",
101+
})
102+
})
103+
104+
it("should handle standard errors with proper format", async () => {
105+
mockCreate.mockImplementationOnce(() => {
106+
throw new Error("Standard error")
107+
})
108+
109+
const stream = handler.createMessage(systemPrompt, messages)
110+
try {
111+
for await (const _ of stream) {
112+
// consume stream
113+
}
114+
fail("Should have thrown an error")
115+
} catch (error) {
116+
expect(error).toBeInstanceOf(Error)
117+
const parsedError = JSON.parse((error as Error).message)
118+
expect(parsedError.status).toBe(500)
119+
expect(parsedError.message).toBe("Standard error")
120+
expect(parsedError.error.metadata.raw).toBe("Standard error")
121+
}
122+
})
123+
124+
it("should handle rate limit errors with proper format", async () => {
125+
mockCreate.mockImplementationOnce(() => {
126+
const error = new Error("Rate limit exceeded") as ApiError
127+
error.status = 429
128+
throw error
129+
})
130+
131+
const stream = handler.createMessage(systemPrompt, messages)
132+
try {
133+
for await (const _ of stream) {
134+
// consume stream
135+
}
136+
fail("Should have thrown an error")
137+
} catch (error) {
138+
expect(error).toBeInstanceOf(Error)
139+
const parsedError = JSON.parse((error as Error).message)
140+
expect(parsedError.status).toBe(429)
141+
expect(parsedError.message).toBe("Rate limit exceeded")
142+
expect(parsedError.errorDetails[0]["@type"]).toBe("type.googleapis.com/google.rpc.RetryInfo")
143+
}
144+
})
145+
146+
it("should handle rate limit errors with message text", async () => {
147+
mockCreate.mockImplementationOnce(() => {
148+
throw new Error("You have exceeded your rate limit")
149+
})
150+
151+
const stream = handler.createMessage(systemPrompt, messages)
152+
try {
153+
for await (const _ of stream) {
154+
// consume stream
155+
}
156+
fail("Should have thrown an error")
157+
} catch (error) {
158+
expect(error).toBeInstanceOf(Error)
159+
const parsedError = JSON.parse((error as Error).message)
160+
expect(parsedError.status).toBe(429)
161+
expect(parsedError.message).toBe("Rate limit exceeded")
162+
}
163+
})
164+
165+
it("should handle network errors with proper format", async () => {
166+
mockCreate.mockImplementationOnce(() => {
167+
const error = new Error("Network error") as ApiError
168+
error.status = 503
169+
throw error
170+
})
171+
172+
const stream = handler.createMessage(systemPrompt, messages)
173+
try {
174+
for await (const _ of stream) {
175+
// consume stream
176+
}
177+
fail("Should have thrown an error")
178+
} catch (error) {
179+
expect(error).toBeInstanceOf(Error)
180+
const parsedError = JSON.parse((error as Error).message)
181+
expect(parsedError.status).toBe(503)
182+
expect(parsedError.message).toBe("Network error")
183+
}
184+
})
185+
186+
it("should handle authentication errors with proper format", async () => {
187+
mockCreate.mockImplementationOnce(() => {
188+
const error = new Error("Invalid API key") as ApiError
189+
error.status = 401
190+
throw error
191+
})
192+
193+
const stream = handler.createMessage(systemPrompt, messages)
194+
try {
195+
for await (const _ of stream) {
196+
// consume stream
197+
}
198+
fail("Should have thrown an error")
199+
} catch (error) {
200+
expect(error).toBeInstanceOf(Error)
201+
const parsedError = JSON.parse((error as Error).message)
202+
expect(parsedError.status).toBe(401)
203+
expect(parsedError.message).toBe("Authentication error")
204+
}
205+
})
206+
207+
it("should handle object errors with proper format", async () => {
208+
mockCreate.mockImplementationOnce(() => {
209+
const error = {
210+
status: 400,
211+
message: "Bad request",
212+
error: {
213+
type: "invalid_request_error",
214+
param: "model",
215+
},
216+
}
217+
// Throw as object directly to test object error handling
218+
throw error
219+
})
220+
221+
const stream = handler.createMessage(systemPrompt, messages)
222+
try {
223+
for await (const _ of stream) {
224+
// consume stream
225+
}
226+
fail("Should have thrown an error")
227+
} catch (error) {
228+
expect(error).toBeInstanceOf(Error)
229+
const parsedError = JSON.parse((error as Error).message)
230+
expect(parsedError.status).toBe(400)
231+
expect(parsedError.message).toBe("Bad request")
232+
expect(parsedError.error.metadata.param).toBe("model")
233+
}
234+
})
235+
236+
it("should handle errors during streaming", async () => {
237+
// Mock a stream that throws after yielding some data
238+
mockCreate.mockResolvedValueOnce({
239+
async *[Symbol.asyncIterator]() {
240+
yield {
241+
type: "message_start",
242+
message: {
243+
usage: {
244+
input_tokens: 100,
245+
output_tokens: 50,
246+
},
247+
},
248+
}
249+
yield {
250+
type: "content_block_start",
251+
index: 0,
252+
content_block: {
253+
type: "text",
254+
text: "This is the beginning of a response",
255+
},
256+
}
257+
// Simulate error during streaming
258+
throw new Error("Stream interrupted")
259+
},
260+
})
261+
262+
const stream = handler.createMessage(systemPrompt, messages)
263+
const chunks: ApiStreamChunk[] = []
264+
265+
try {
266+
for await (const chunk of stream) {
267+
chunks.push(chunk)
268+
}
269+
fail("Expected error to be thrown")
270+
} catch (error) {
271+
expect(error).toBeInstanceOf(Error)
272+
// Parse the error message as JSON
273+
const parsedError = JSON.parse((error as Error).message)
274+
expect(parsedError.status).toBe(500)
275+
expect(parsedError.message).toBe("Stream interrupted")
276+
// Verify we got some chunks before the error
277+
expect(chunks.length).toBeGreaterThan(0)
278+
}
279+
})
280+
281+
it("should handle thinking mode for supported models", async () => {
282+
const thinkingHandler = new AnthropicHandler({
283+
apiKey: "test-api-key",
284+
apiModelId: "claude-3-7-sonnet-20250219:thinking",
285+
modelMaxThinkingTokens: 16384,
286+
})
287+
288+
const thinkingMockCreate = thinkingHandler["client"].messages.create as jest.Mock
289+
290+
// Mock a stream with thinking content
291+
thinkingMockCreate.mockResolvedValueOnce({
292+
async *[Symbol.asyncIterator]() {
293+
yield {
294+
type: "message_start",
295+
message: {
296+
usage: {
297+
input_tokens: 100,
298+
output_tokens: 50,
299+
},
300+
},
301+
}
302+
yield {
303+
type: "content_block_start",
304+
index: 0,
305+
content_block: {
306+
type: "thinking",
307+
thinking: "Let me think about this...",
308+
},
309+
}
310+
yield {
311+
type: "content_block_delta",
312+
delta: {
313+
type: "thinking_delta",
314+
thinking: " I need to consider all options.",
315+
},
316+
}
317+
yield {
318+
type: "content_block_start",
319+
index: 1,
320+
content_block: {
321+
type: "text",
322+
text: "Here's my answer:",
323+
},
324+
}
325+
// We need to make sure the text is properly captured
326+
// The issue is that the handler might be ignoring empty text deltas
327+
// Let's add a non-empty text delta
328+
yield {
329+
type: "content_block_delta",
330+
delta: {
331+
type: "text_delta",
332+
text: " Additional text",
333+
},
334+
}
335+
},
336+
})
337+
338+
const stream = thinkingHandler.createMessage(systemPrompt, messages)
339+
const chunks: ApiStreamChunk[] = []
340+
341+
for await (const chunk of stream) {
342+
chunks.push(chunk)
343+
}
344+
345+
// Verify we got thinking chunks
346+
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
347+
expect(reasoningChunks.length).toBeGreaterThan(0)
348+
expect(reasoningChunks[0].text).toBe("Let me think about this...")
349+
expect(reasoningChunks[1].text).toBe(" I need to consider all options.")
350+
351+
// Verify we also got text chunks
352+
const textChunks = chunks.filter((chunk) => chunk.type === "text")
353+
expect(textChunks.length).toBeGreaterThan(0)
354+
355+
// The first text chunk is a newline because chunk.index > 0 in the handler
356+
expect(textChunks[0].text).toBe("\n")
357+
// The second text chunk is from content_block_start
358+
expect(textChunks[1].text).toBe("Here's my answer:")
359+
// The third text chunk is from content_block_delta
360+
expect(textChunks[2].text).toBe(" Additional text")
361+
362+
// Verify the API was called with thinking parameter
363+
expect(thinkingMockCreate).toHaveBeenCalledWith(
364+
expect.objectContaining({
365+
thinking: { type: "enabled", budget_tokens: 16384 },
366+
}),
367+
expect.anything(),
368+
)
369+
})
370+
})

0 commit comments

Comments
 (0)