fix: improve error handling for Qwen API 400 status codes

- Add comprehensive error handling in QwenCodeHandler.callApiWithRetry
- Handle 400, 403, 429, and 5xx errors with user-friendly messages
- Add try-catch blocks to createMessage and completePrompt methods
- Include detailed error context to help users understand issues
- Add comprehensive test coverage for error scenarios

Fixes #8142
This commit is contained in:
Roo Code 2025-09-18 11:29:54 +00:00
parent 87b45def18
commit 37925d71ce
2 changed files with 309 additions and 71 deletions

View file

@ -0,0 +1,191 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { QwenCodeHandler } from "../qwen-code"
import { promises as fs } from "node:fs"
import OpenAI from "openai"
// Mock fs module
vi.mock("node:fs", () => ({
promises: {
readFile: vi.fn(),
writeFile: vi.fn(),
},
}))
// Mock OpenAI
vi.mock("openai", () => {
const mockCreate = vi.fn()
return {
default: vi.fn().mockImplementation(() => ({
chat: {
completions: {
create: mockCreate,
},
},
})),
}
})
// Mock fetch for OAuth token refresh
global.fetch = vi.fn()
describe("QwenCodeHandler", () => {
let handler: QwenCodeHandler
const mockCredentials = {
access_token: "test-access-token",
refresh_token: "test-refresh-token",
token_type: "Bearer",
expiry_date: Date.now() + 3600000, // 1 hour from now
resource_url: "https://dashscope.aliyuncs.com/compatible-mode/v1",
}
beforeEach(() => {
vi.clearAllMocks()
// Mock reading credentials file
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockCredentials))
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
handler = new QwenCodeHandler({
apiModelId: "qwen-max",
})
})
afterEach(() => {
vi.clearAllMocks()
})
describe("Error Handling", () => {
it("should handle 400 errors with user-friendly message", async () => {
const mockClient = new OpenAI({ apiKey: "test" })
const mockError = {
status: 400,
message: "Invalid request format",
}
vi.mocked(mockClient.chat.completions.create).mockRejectedValue(mockError)
// Override the ensureClient method to return our mock
handler["client"] = mockClient
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
"Qwen API Error (400): Invalid request format. This may be due to invalid input format, unsupported file type, or request size limits.",
)
})
it("should handle 401 errors and attempt token refresh", async () => {
const mockClient = new OpenAI({ apiKey: "test" })
const mockError = {
status: 401,
message: "Unauthorized",
}
// First call fails with 401, second succeeds after refresh
vi.mocked(mockClient.chat.completions.create)
.mockRejectedValueOnce(mockError)
.mockResolvedValueOnce({
choices: [{ message: { content: "Success after refresh" } }],
} as any)
// Mock successful token refresh
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
access_token: "new-access-token",
token_type: "Bearer",
expires_in: 3600,
refresh_token: "new-refresh-token",
}),
} as any)
handler["client"] = mockClient
const result = await handler.completePrompt("test prompt")
expect(result).toBe("Success after refresh")
expect(global.fetch).toHaveBeenCalledTimes(1)
})
it("should handle 403 errors with permission message", async () => {
const mockClient = new OpenAI({ apiKey: "test" })
const mockError = {
status: 403,
message: "Access denied",
}
vi.mocked(mockClient.chat.completions.create).mockRejectedValue(mockError)
handler["client"] = mockClient
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
"Qwen API Error (403): Access denied. Please check your API permissions.",
)
})
it("should handle 429 rate limit errors", async () => {
const mockClient = new OpenAI({ apiKey: "test" })
const mockError = {
status: 429,
message: "Too many requests",
}
vi.mocked(mockClient.chat.completions.create).mockRejectedValue(mockError)
handler["client"] = mockClient
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
"Qwen API Error (429): Too many requests. Please wait before making more requests.",
)
})
it("should handle 500+ server errors", async () => {
const mockClient = new OpenAI({ apiKey: "test" })
const mockError = {
status: 503,
message: "Service unavailable",
}
vi.mocked(mockClient.chat.completions.create).mockRejectedValue(mockError)
handler["client"] = mockClient
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
"Qwen API Error (503): Service unavailable. The Qwen service may be temporarily unavailable.",
)
})
it("should handle generic errors with context", async () => {
const mockClient = new OpenAI({ apiKey: "test" })
const mockError = new Error("Network timeout")
vi.mocked(mockClient.chat.completions.create).mockRejectedValue(mockError)
handler["client"] = mockClient
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
"Failed to complete prompt with Qwen model: Network timeout",
)
})
it("should preserve already formatted Qwen API errors", async () => {
const mockClient = new OpenAI({ apiKey: "test" })
const mockError = new Error("Qwen API Error (400): Already formatted error")
vi.mocked(mockClient.chat.completions.create).mockRejectedValue(mockError)
handler["client"] = mockClient
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
"Qwen API Error (400): Already formatted error",
)
})
})
describe("Model Configuration", () => {
it("should return correct model info", () => {
const model = handler.getModel()
expect(model.id).toBe("qwen-max")
expect(model.info).toBeDefined()
expect(model.info.maxTokens).toBeGreaterThan(0)
})
it("should use default model when not specified", () => {
const defaultHandler = new QwenCodeHandler({})
const model = defaultHandler.getModel()
expect(model.id).toBeDefined()
expect(model.info).toBeDefined()
})
})
})

View file

@ -195,92 +195,128 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan
client.apiKey = this.credentials.access_token
client.baseURL = this.getBaseUrl(this.credentials)
return await apiCall()
} else if (error.status === 400) {
// Bad request error - provide more context
const errorMessage = error?.message || error?.error?.message || "Bad request"
const detailedMessage = `Qwen API Error (400): ${errorMessage}. This may be due to invalid input format, unsupported file type, or request size limits.`
console.error(detailedMessage)
throw new Error(detailedMessage)
} else if (error.status === 403) {
// Forbidden error
const errorMessage = error?.message || error?.error?.message || "Forbidden"
throw new Error(`Qwen API Error (403): ${errorMessage}. Please check your API permissions.`)
} else if (error.status === 429) {
// Rate limit error
const errorMessage = error?.message || error?.error?.message || "Rate limit exceeded"
throw new Error(`Qwen API Error (429): ${errorMessage}. Please wait before making more requests.`)
} else if (error.status >= 500) {
// Server error
const errorMessage = error?.message || error?.error?.message || "Server error"
throw new Error(
`Qwen API Error (${error.status}): ${errorMessage}. The Qwen service may be temporarily unavailable.`,
)
} else {
// Other errors - preserve original error but add context
if (error.status) {
const errorMessage = error?.message || error?.error?.message || "Unknown error"
throw new Error(`Qwen API Error (${error.status}): ${errorMessage}`)
}
throw error
}
}
}
override async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
await this.ensureAuthenticated()
const client = this.ensureClient()
const model = this.getModel()
try {
await this.ensureAuthenticated()
const client = this.ensureClient()
const model = this.getModel()
const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
role: "system",
content: systemPrompt,
}
const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
role: "system",
content: systemPrompt,
}
const convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)]
const convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)]
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: model.id,
temperature: 0,
messages: convertedMessages,
stream: true,
stream_options: { include_usage: true },
max_completion_tokens: model.info.maxTokens,
}
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: model.id,
temperature: 0,
messages: convertedMessages,
stream: true,
stream_options: { include_usage: true },
max_completion_tokens: model.info.maxTokens,
}
const stream = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions))
const stream = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions))
let fullContent = ""
let fullContent = ""
for await (const apiChunk of stream) {
const delta = apiChunk.choices[0]?.delta ?? {}
for await (const apiChunk of stream) {
const delta = apiChunk.choices[0]?.delta ?? {}
if (delta.content) {
let newText = delta.content
if (newText.startsWith(fullContent)) {
newText = newText.substring(fullContent.length)
}
fullContent = delta.content
if (delta.content) {
let newText = delta.content
if (newText.startsWith(fullContent)) {
newText = newText.substring(fullContent.length)
}
fullContent = delta.content
if (newText) {
// Check for thinking blocks
if (newText.includes("<think>") || newText.includes("</think>")) {
// Simple parsing for thinking blocks
const parts = newText.split(/<\/?think>/g)
for (let i = 0; i < parts.length; i++) {
if (parts[i]) {
if (i % 2 === 0) {
// Outside thinking block
yield {
type: "text",
text: parts[i],
}
} else {
// Inside thinking block
yield {
type: "reasoning",
text: parts[i],
if (newText) {
// Check for thinking blocks
if (newText.includes("<think>") || newText.includes("</think>")) {
// Simple parsing for thinking blocks
const parts = newText.split(/<\/?think>/g)
for (let i = 0; i < parts.length; i++) {
if (parts[i]) {
if (i % 2 === 0) {
// Outside thinking block
yield {
type: "text",
text: parts[i],
}
} else {
// Inside thinking block
yield {
type: "reasoning",
text: parts[i],
}
}
}
}
}
} else {
yield {
type: "text",
text: newText,
} else {
yield {
type: "text",
text: newText,
}
}
}
}
}
if ("reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
text: (delta.reasoning_content as string | undefined) || "",
if ("reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
text: (delta.reasoning_content as string | undefined) || "",
}
}
if (apiChunk.usage) {
yield {
type: "usage",
inputTokens: apiChunk.usage.prompt_tokens || 0,
outputTokens: apiChunk.usage.completion_tokens || 0,
}
}
}
} catch (error: any) {
// Log the error for debugging
console.error("Error in QwenCodeHandler.createMessage:", error)
if (apiChunk.usage) {
yield {
type: "usage",
inputTokens: apiChunk.usage.prompt_tokens || 0,
outputTokens: apiChunk.usage.completion_tokens || 0,
}
// Re-throw with a more user-friendly message if it's not already formatted
if (error.message && !error.message.startsWith("Qwen API Error")) {
throw new Error(`Failed to process request with Qwen model: ${error.message}`)
}
throw error
}
}
@ -291,18 +327,29 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan
}
async completePrompt(prompt: string): Promise<string> {
await this.ensureAuthenticated()
const client = this.ensureClient()
const model = this.getModel()
try {
await this.ensureAuthenticated()
const client = this.ensureClient()
const model = this.getModel()
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
model: model.id,
messages: [{ role: "user", content: prompt }],
max_completion_tokens: model.info.maxTokens,
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
model: model.id,
messages: [{ role: "user", content: prompt }],
max_completion_tokens: model.info.maxTokens,
}
const response = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions))
return response.choices[0]?.message.content || ""
} catch (error: any) {
// Log the error for debugging
console.error("Error in QwenCodeHandler.completePrompt:", error)
// Re-throw with a more user-friendly message if it's not already formatted
if (error.message && !error.message.startsWith("Qwen API Error")) {
throw new Error(`Failed to complete prompt with Qwen model: ${error.message}`)
}
throw error
}
const response = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions))
return response.choices[0]?.message.content || ""
}
}