Skip to content

Commit 3651928

Browse files
committed
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
1 parent 48d592f commit 3651928

File tree

3 files changed

+239
-47
lines changed

3 files changed

+239
-47
lines changed

src/api/providers/__tests__/chutes.spec.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,64 @@ describe("ChutesHandler", () => {
329329
it("should handle errors in completePrompt", async () => {
330330
const errorMessage = "Chutes API error"
331331
mockCreate.mockRejectedValueOnce(new Error(errorMessage))
332-
await expect(handler.completePrompt("test prompt")).rejects.toThrow(`Chutes completion error: ${errorMessage}`)
332+
await expect(handler.completePrompt("test prompt")).rejects.toThrow(/ChutesAI completion error/)
333+
})
334+
335+
it("should retry on 500 errors and succeed", async () => {
336+
const error500 = new Error("Internal Server Error")
337+
;(error500 as any).status = 500
338+
339+
// First attempt fails with 500, second succeeds
340+
mockCreate
341+
.mockRejectedValueOnce(error500)
342+
.mockResolvedValueOnce({ choices: [{ message: { content: "Success after retry" } }] })
343+
344+
const result = await handler.completePrompt("test prompt")
345+
expect(result).toBe("Success after retry")
346+
expect(mockCreate).toHaveBeenCalledTimes(2)
347+
})
348+
349+
it("should handle 500 errors with empty response body", async () => {
350+
const error500 = new Error("")
351+
;(error500 as any).status = 500
352+
353+
// All attempts fail with empty error
354+
mockCreate.mockRejectedValue(error500)
355+
356+
await expect(handler.completePrompt("test prompt")).rejects.toThrow(/ChutesAI completion error.*500/)
357+
})
358+
359+
it("should not retry on 4xx errors", async () => {
360+
const error400 = new Error("Bad Request")
361+
;(error400 as any).status = 400
362+
363+
mockCreate.mockRejectedValueOnce(error400)
364+
365+
await expect(handler.completePrompt("test prompt")).rejects.toThrow(/ChutesAI completion error.*400/)
366+
expect(mockCreate).toHaveBeenCalledTimes(1) // Should not retry
367+
})
368+
369+
it("should handle streaming errors with retry", async () => {
370+
const error500 = new Error("Stream failed")
371+
;(error500 as any).status = 500
372+
373+
// First attempt fails, second succeeds
374+
mockCreate.mockRejectedValueOnce(error500).mockImplementationOnce(async () => ({
375+
[Symbol.asyncIterator]: async function* () {
376+
yield {
377+
choices: [{ delta: { content: "Retry success" } }],
378+
usage: null,
379+
}
380+
},
381+
}))
382+
383+
const stream = handler.createMessage("system", [])
384+
const chunks = []
385+
for await (const chunk of stream) {
386+
chunks.push(chunk)
387+
}
388+
389+
expect(chunks).toContainEqual({ type: "text", text: "Retry success" })
333390
})
334391

335392
it("createMessage should yield text content from stream", async () => {

src/api/providers/base-openai-compatible-provider.ts

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,24 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
8787

8888
try {
8989
return this.client.chat.completions.create(params, requestOptions)
90-
} catch (error) {
90+
} catch (error: any) {
91+
// Log the raw error for debugging
92+
console.error(`${this.providerName} raw error:`, {
93+
message: error.message,
94+
status: error.status,
95+
statusText: error.statusText,
96+
response: error.response,
97+
cause: error.cause,
98+
stack: error.stack,
99+
})
100+
101+
// If it's an OpenAI API error with status code, preserve it
102+
if (error.status) {
103+
const enhancedError = handleOpenAIError(error, this.providerName)
104+
;(enhancedError as any).status = error.status
105+
throw enhancedError
106+
}
107+
91108
throw handleOpenAIError(error, this.providerName)
92109
}
93110
}
@@ -97,25 +114,44 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
97114
messages: Anthropic.Messages.MessageParam[],
98115
metadata?: ApiHandlerCreateMessageMetadata,
99116
): ApiStream {
100-
const stream = await this.createStream(systemPrompt, messages, metadata)
117+
try {
118+
const stream = await this.createStream(systemPrompt, messages, metadata)
101119

102-
for await (const chunk of stream) {
103-
const delta = chunk.choices[0]?.delta
120+
for await (const chunk of stream) {
121+
const delta = chunk.choices[0]?.delta
104122

105-
if (delta?.content) {
106-
yield {
107-
type: "text",
108-
text: delta.content,
123+
if (delta?.content) {
124+
yield {
125+
type: "text",
126+
text: delta.content,
127+
}
109128
}
110-
}
111129

112-
if (chunk.usage) {
113-
yield {
114-
type: "usage",
115-
inputTokens: chunk.usage.prompt_tokens || 0,
116-
outputTokens: chunk.usage.completion_tokens || 0,
130+
if (chunk.usage) {
131+
yield {
132+
type: "usage",
133+
inputTokens: chunk.usage.prompt_tokens || 0,
134+
outputTokens: chunk.usage.completion_tokens || 0,
135+
}
117136
}
118137
}
138+
} catch (error: any) {
139+
// Log detailed error information
140+
console.error(`${this.providerName} streaming error:`, {
141+
message: error.message,
142+
status: error.status,
143+
statusText: error.statusText,
144+
type: error.type,
145+
code: error.code,
146+
})
147+
148+
// Re-throw with status preserved
149+
if (error.status) {
150+
const enhancedError = new Error(error.message || `${this.providerName} streaming failed`)
151+
;(enhancedError as any).status = error.status
152+
throw enhancedError
153+
}
154+
throw error
119155
}
120156
}
121157

@@ -129,7 +165,22 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
129165
})
130166

131167
return response.choices[0]?.message.content || ""
132-
} catch (error) {
168+
} catch (error: any) {
169+
// Log the raw error for debugging
170+
console.error(`${this.providerName} completePrompt raw error:`, {
171+
message: error.message,
172+
status: error.status,
173+
statusText: error.statusText,
174+
response: error.response,
175+
})
176+
177+
// If it's an OpenAI API error with status code, preserve it
178+
if (error.status) {
179+
const enhancedError = handleOpenAIError(error, this.providerName)
180+
;(enhancedError as any).status = error.status
181+
throw enhancedError
182+
}
183+
133184
throw handleOpenAIError(error, this.providerName)
134185
}
135186
}

src/api/providers/chutes.ts

Lines changed: 115 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import { ApiStream } from "../transform/stream"
1111
import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider"
1212

1313
export class ChutesHandler extends BaseOpenAiCompatibleProvider<ChutesModelId> {
14+
private retryCount = 3
15+
private retryDelay = 1000 // Start with 1 second delay
16+
1417
constructor(options: ApiHandlerOptions) {
1518
super({
1619
...options,
@@ -47,46 +50,127 @@ export class ChutesHandler extends BaseOpenAiCompatibleProvider<ChutesModelId> {
4750
override async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
4851
const model = this.getModel()
4952

50-
if (model.id.includes("DeepSeek-R1")) {
51-
const stream = await this.client.chat.completions.create({
52-
...this.getCompletionParams(systemPrompt, messages),
53-
messages: convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]),
54-
})
55-
56-
const matcher = new XmlMatcher(
57-
"think",
58-
(chunk) =>
59-
({
60-
type: chunk.matched ? "reasoning" : "text",
61-
text: chunk.data,
62-
}) as const,
63-
)
64-
65-
for await (const chunk of stream) {
66-
const delta = chunk.choices[0]?.delta
67-
68-
if (delta?.content) {
69-
for (const processedChunk of matcher.update(delta.content)) {
53+
// Add retry logic for transient errors
54+
let lastError: Error | null = null
55+
for (let attempt = 0; attempt < this.retryCount; attempt++) {
56+
try {
57+
if (model.id.includes("DeepSeek-R1")) {
58+
const stream = await this.client.chat.completions.create({
59+
...this.getCompletionParams(systemPrompt, messages),
60+
messages: convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]),
61+
})
62+
63+
const matcher = new XmlMatcher(
64+
"think",
65+
(chunk) =>
66+
({
67+
type: chunk.matched ? "reasoning" : "text",
68+
text: chunk.data,
69+
}) as const,
70+
)
71+
72+
for await (const chunk of stream) {
73+
const delta = chunk.choices[0]?.delta
74+
75+
if (delta?.content) {
76+
for (const processedChunk of matcher.update(delta.content)) {
77+
yield processedChunk
78+
}
79+
}
80+
81+
if (chunk.usage) {
82+
yield {
83+
type: "usage",
84+
inputTokens: chunk.usage.prompt_tokens || 0,
85+
outputTokens: chunk.usage.completion_tokens || 0,
86+
}
87+
}
88+
}
89+
90+
// Process any remaining content
91+
for (const processedChunk of matcher.final()) {
7092
yield processedChunk
7193
}
94+
return // Success, exit the retry loop
95+
} else {
96+
yield* super.createMessage(systemPrompt, messages)
97+
return // Success, exit the retry loop
7298
}
99+
} catch (error: any) {
100+
lastError = error
101+
console.error(`ChutesAI API error (attempt ${attempt + 1}/${this.retryCount}):`, {
102+
status: error.status,
103+
message: error.message,
104+
response: error.response,
105+
cause: error.cause,
106+
})
73107

74-
if (chunk.usage) {
75-
yield {
76-
type: "usage",
77-
inputTokens: chunk.usage.prompt_tokens || 0,
78-
outputTokens: chunk.usage.completion_tokens || 0,
79-
}
108+
// Check if it's a retryable error (5xx errors)
109+
if (error.status && error.status >= 500 && error.status < 600 && attempt < this.retryCount - 1) {
110+
// Exponential backoff
111+
const delay = this.retryDelay * Math.pow(2, attempt)
112+
console.log(`Retrying ChutesAI request after ${delay}ms...`)
113+
await new Promise((resolve) => setTimeout(resolve, delay))
114+
continue
80115
}
116+
117+
// For non-retryable errors or final attempt, throw with more context
118+
const enhancedError = new Error(
119+
`ChutesAI API error (${error.status || "unknown status"}): ${error.message || "Empty response body"}. ` +
120+
`This may be a temporary issue with the ChutesAI service. ` +
121+
`Please verify your API key and try again.`,
122+
)
123+
;(enhancedError as any).status = error.status
124+
;(enhancedError as any).originalError = error
125+
throw enhancedError
81126
}
127+
}
82128

83-
// Process any remaining content
84-
for (const processedChunk of matcher.final()) {
85-
yield processedChunk
129+
// If we've exhausted all retries
130+
if (lastError) {
131+
throw lastError
132+
}
133+
}
134+
135+
override async completePrompt(prompt: string): Promise<string> {
136+
let lastError: Error | null = null
137+
138+
for (let attempt = 0; attempt < this.retryCount; attempt++) {
139+
try {
140+
return await super.completePrompt(prompt)
141+
} catch (error: any) {
142+
lastError = error
143+
console.error(`ChutesAI completePrompt error (attempt ${attempt + 1}/${this.retryCount}):`, {
144+
status: error.status,
145+
message: error.message,
146+
})
147+
148+
// Check if it's a retryable error (5xx errors)
149+
if (error.status && error.status >= 500 && error.status < 600 && attempt < this.retryCount - 1) {
150+
// Exponential backoff
151+
const delay = this.retryDelay * Math.pow(2, attempt)
152+
console.log(`Retrying ChutesAI completePrompt after ${delay}ms...`)
153+
await new Promise((resolve) => setTimeout(resolve, delay))
154+
continue
155+
}
156+
157+
// For non-retryable errors or final attempt, throw with more context
158+
const enhancedError = new Error(
159+
`ChutesAI completion error (${error.status || "unknown status"}): ${error.message || "Empty response body"}. ` +
160+
`Please verify your API key and endpoint configuration.`,
161+
)
162+
;(enhancedError as any).status = error.status
163+
;(enhancedError as any).originalError = error
164+
throw enhancedError
86165
}
87-
} else {
88-
yield* super.createMessage(systemPrompt, messages)
89166
}
167+
168+
// If we've exhausted all retries
169+
if (lastError) {
170+
throw lastError
171+
}
172+
173+
throw new Error("ChutesAI completion failed after all retry attempts")
90174
}
91175

92176
override getModel() {

0 commit comments

Comments
 (0)