fix: improve ChutesAI error handling for HTTP 500 errors

- Add retry logic with exponential backoff for transient 500 errors
- Enhance error messages to provide more context when API returns empty response
- Add detailed logging for debugging API errors
- Preserve HTTP status codes in error objects for better error handling
- Add comprehensive test coverage for error scenarios

Fixes #7832
This commit is contained in:
Roo Code 2025-09-10 00:25:32 +00:00
parent 48d592f433
commit 365192829a
3 changed files with 239 additions and 47 deletions

View file

@ -329,7 +329,64 @@ describe("ChutesHandler", () => {
it("should handle errors in completePrompt", async () => {
const errorMessage = "Chutes API error"
mockCreate.mockRejectedValueOnce(new Error(errorMessage))
await expect(handler.completePrompt("test prompt")).rejects.toThrow(`Chutes completion error: ${errorMessage}`)
await expect(handler.completePrompt("test prompt")).rejects.toThrow(/ChutesAI completion error/)
})
it("should retry on 500 errors and succeed", async () => {
const error500 = new Error("Internal Server Error")
;(error500 as any).status = 500
// First attempt fails with 500, second succeeds
mockCreate
.mockRejectedValueOnce(error500)
.mockResolvedValueOnce({ choices: [{ message: { content: "Success after retry" } }] })
const result = await handler.completePrompt("test prompt")
expect(result).toBe("Success after retry")
expect(mockCreate).toHaveBeenCalledTimes(2)
})
it("should handle 500 errors with empty response body", async () => {
const error500 = new Error("")
;(error500 as any).status = 500
// All attempts fail with empty error
mockCreate.mockRejectedValue(error500)
await expect(handler.completePrompt("test prompt")).rejects.toThrow(/ChutesAI completion error.*500/)
})
it("should not retry on 4xx errors", async () => {
const error400 = new Error("Bad Request")
;(error400 as any).status = 400
mockCreate.mockRejectedValueOnce(error400)
await expect(handler.completePrompt("test prompt")).rejects.toThrow(/ChutesAI completion error.*400/)
expect(mockCreate).toHaveBeenCalledTimes(1) // Should not retry
})
it("should handle streaming errors with retry", async () => {
const error500 = new Error("Stream failed")
;(error500 as any).status = 500
// First attempt fails, second succeeds
mockCreate.mockRejectedValueOnce(error500).mockImplementationOnce(async () => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [{ delta: { content: "Retry success" } }],
usage: null,
}
},
}))
const stream = handler.createMessage("system", [])
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks).toContainEqual({ type: "text", text: "Retry success" })
})
it("createMessage should yield text content from stream", async () => {

View file

@ -87,7 +87,24 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
try {
return this.client.chat.completions.create(params, requestOptions)
} catch (error) {
} catch (error: any) {
// Log the raw error for debugging
console.error(`${this.providerName} raw error:`, {
message: error.message,
status: error.status,
statusText: error.statusText,
response: error.response,
cause: error.cause,
stack: error.stack,
})
// If it's an OpenAI API error with status code, preserve it
if (error.status) {
const enhancedError = handleOpenAIError(error, this.providerName)
;(enhancedError as any).status = error.status
throw enhancedError
}
throw handleOpenAIError(error, this.providerName)
}
}
@ -97,25 +114,44 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
const stream = await this.createStream(systemPrompt, messages, metadata)
try {
const stream = await this.createStream(systemPrompt, messages, metadata)
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
}
} catch (error: any) {
// Log detailed error information
console.error(`${this.providerName} streaming error:`, {
message: error.message,
status: error.status,
statusText: error.statusText,
type: error.type,
code: error.code,
})
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
// Re-throw with status preserved
if (error.status) {
const enhancedError = new Error(error.message || `${this.providerName} streaming failed`)
;(enhancedError as any).status = error.status
throw enhancedError
}
throw error
}
}
@ -129,7 +165,22 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
})
return response.choices[0]?.message.content || ""
} catch (error) {
} catch (error: any) {
// Log the raw error for debugging
console.error(`${this.providerName} completePrompt raw error:`, {
message: error.message,
status: error.status,
statusText: error.statusText,
response: error.response,
})
// If it's an OpenAI API error with status code, preserve it
if (error.status) {
const enhancedError = handleOpenAIError(error, this.providerName)
;(enhancedError as any).status = error.status
throw enhancedError
}
throw handleOpenAIError(error, this.providerName)
}
}

View file

@ -11,6 +11,9 @@ import { ApiStream } from "../transform/stream"
import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider"
export class ChutesHandler extends BaseOpenAiCompatibleProvider<ChutesModelId> {
private retryCount = 3
private retryDelay = 1000 // Start with 1 second delay
constructor(options: ApiHandlerOptions) {
super({
...options,
@ -47,46 +50,127 @@ export class ChutesHandler extends BaseOpenAiCompatibleProvider<ChutesModelId> {
override async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const model = this.getModel()
if (model.id.includes("DeepSeek-R1")) {
const stream = await this.client.chat.completions.create({
...this.getCompletionParams(systemPrompt, messages),
messages: convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]),
})
// Add retry logic for transient errors
let lastError: Error | null = null
for (let attempt = 0; attempt < this.retryCount; attempt++) {
try {
if (model.id.includes("DeepSeek-R1")) {
const stream = await this.client.chat.completions.create({
...this.getCompletionParams(systemPrompt, messages),
messages: convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]),
})
const matcher = new XmlMatcher(
"think",
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
text: chunk.data,
}) as const,
)
const matcher = new XmlMatcher(
"think",
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
text: chunk.data,
}) as const,
)
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
for (const processedChunk of matcher.update(delta.content)) {
if (delta?.content) {
for (const processedChunk of matcher.update(delta.content)) {
yield processedChunk
}
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
}
// Process any remaining content
for (const processedChunk of matcher.final()) {
yield processedChunk
}
return // Success, exit the retry loop
} else {
yield* super.createMessage(systemPrompt, messages)
return // Success, exit the retry loop
}
} catch (error: any) {
lastError = error
console.error(`ChutesAI API error (attempt ${attempt + 1}/${this.retryCount}):`, {
status: error.status,
message: error.message,
response: error.response,
cause: error.cause,
})
// Check if it's a retryable error (5xx errors)
if (error.status && error.status >= 500 && error.status < 600 && attempt < this.retryCount - 1) {
// Exponential backoff
const delay = this.retryDelay * Math.pow(2, attempt)
console.log(`Retrying ChutesAI request after ${delay}ms...`)
await new Promise((resolve) => setTimeout(resolve, delay))
continue
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
// For non-retryable errors or final attempt, throw with more context
const enhancedError = new Error(
`ChutesAI API error (${error.status || "unknown status"}): ${error.message || "Empty response body"}. ` +
`This may be a temporary issue with the ChutesAI service. ` +
`Please verify your API key and try again.`,
)
;(enhancedError as any).status = error.status
;(enhancedError as any).originalError = error
throw enhancedError
}
// Process any remaining content
for (const processedChunk of matcher.final()) {
yield processedChunk
}
} else {
yield* super.createMessage(systemPrompt, messages)
}
// If we've exhausted all retries
if (lastError) {
throw lastError
}
}
override async completePrompt(prompt: string): Promise<string> {
let lastError: Error | null = null
for (let attempt = 0; attempt < this.retryCount; attempt++) {
try {
return await super.completePrompt(prompt)
} catch (error: any) {
lastError = error
console.error(`ChutesAI completePrompt error (attempt ${attempt + 1}/${this.retryCount}):`, {
status: error.status,
message: error.message,
})
// Check if it's a retryable error (5xx errors)
if (error.status && error.status >= 500 && error.status < 600 && attempt < this.retryCount - 1) {
// Exponential backoff
const delay = this.retryDelay * Math.pow(2, attempt)
console.log(`Retrying ChutesAI completePrompt after ${delay}ms...`)
await new Promise((resolve) => setTimeout(resolve, delay))
continue
}
// For non-retryable errors or final attempt, throw with more context
const enhancedError = new Error(
`ChutesAI completion error (${error.status || "unknown status"}): ${error.message || "Empty response body"}. ` +
`Please verify your API key and endpoint configuration.`,
)
;(enhancedError as any).status = error.status
;(enhancedError as any).originalError = error
throw enhancedError
}
}
// If we've exhausted all retries
if (lastError) {
throw lastError
}
throw new Error("ChutesAI completion failed after all retry attempts")
}
override getModel() {