Skip to content

Commit c2c2cf5

Browse files
committed
fix: improve Gemini API error handling and resilience
- Add retry logic with exponential backoff for transient errors - Implement error classification for better error handling - Add fallback mechanism for blank responses - Improve error messages with more context - Handle rate limits with proper retry delays - Add comprehensive tests for error scenarios Fixes #7192
1 parent b975ced commit c2c2cf5

File tree

2 files changed

+341
-8
lines changed

2 files changed

+341
-8
lines changed

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

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import { GeminiHandler } from "../gemini"
99

1010
const GEMINI_20_FLASH_THINKING_NAME = "gemini-2.0-flash-thinking-exp-1219"
1111

12+
// Mock delay module
13+
vitest.mock("delay", () => ({
14+
default: vitest.fn(() => Promise.resolve()),
15+
}))
16+
1217
describe("GeminiHandler", () => {
1318
let handler: GeminiHandler
1419

@@ -102,6 +107,107 @@ describe("GeminiHandler", () => {
102107
}
103108
}).rejects.toThrow()
104109
})
110+
111+
it("should retry on rate limit errors", async () => {
112+
const mockError = new Error("Rate limit exceeded")
113+
// @ts-ignore - adding status property to error
114+
mockError.status = 429
115+
116+
const mockStream = {
117+
[Symbol.asyncIterator]: async function* () {
118+
yield {
119+
candidates: [
120+
{
121+
content: {
122+
parts: [{ text: "Success after retry" }],
123+
},
124+
},
125+
],
126+
}
127+
},
128+
}
129+
130+
const generateContentStreamMock = handler["client"].models.generateContentStream as any
131+
generateContentStreamMock.mockRejectedValueOnce(mockError).mockResolvedValueOnce(mockStream)
132+
133+
const chunks: any[] = []
134+
for await (const chunk of handler.createMessage(systemPrompt, mockMessages)) {
135+
chunks.push(chunk)
136+
}
137+
138+
expect(generateContentStreamMock).toHaveBeenCalledTimes(2)
139+
expect(chunks[0]).toEqual({ type: "text", text: "Success after retry" })
140+
})
141+
142+
it("should handle blank responses", async () => {
143+
const mockStream = {
144+
[Symbol.asyncIterator]: async function* () {
145+
// Yield empty chunks
146+
yield {}
147+
yield { candidates: [] }
148+
},
149+
}
150+
151+
;(handler["client"].models.generateContentStream as any).mockResolvedValue(mockStream)
152+
153+
const stream = handler.createMessage(systemPrompt, mockMessages)
154+
155+
await expect(async () => {
156+
for await (const _chunk of stream) {
157+
// Should throw due to blank response
158+
}
159+
}).rejects.toThrow(
160+
t("common:errors.gemini.generate_stream", { error: "Received blank response from Gemini API" }),
161+
)
162+
})
163+
164+
it("should retry on server errors", async () => {
165+
const mockError = new Error("Internal server error")
166+
// @ts-ignore - adding status property to error
167+
mockError.status = 500
168+
169+
const mockStream = {
170+
[Symbol.asyncIterator]: async function* () {
171+
yield {
172+
candidates: [
173+
{
174+
content: {
175+
parts: [{ text: "Success after server error" }],
176+
},
177+
},
178+
],
179+
}
180+
},
181+
}
182+
183+
const generateContentStreamMock = handler["client"].models.generateContentStream as any
184+
generateContentStreamMock.mockRejectedValueOnce(mockError).mockResolvedValueOnce(mockStream)
185+
186+
const chunks: any[] = []
187+
for await (const chunk of handler.createMessage(systemPrompt, mockMessages)) {
188+
chunks.push(chunk)
189+
}
190+
191+
expect(generateContentStreamMock).toHaveBeenCalledTimes(2)
192+
expect(chunks[0]).toEqual({ type: "text", text: "Success after server error" })
193+
})
194+
195+
it("should not retry on authentication errors", async () => {
196+
const mockError = new Error("Invalid API key")
197+
// @ts-ignore - adding status property to error
198+
mockError.status = 401
199+
;(handler["client"].models.generateContentStream as any).mockRejectedValue(mockError)
200+
201+
const stream = handler.createMessage(systemPrompt, mockMessages)
202+
203+
await expect(async () => {
204+
for await (const _chunk of stream) {
205+
// Should throw without retrying
206+
}
207+
}).rejects.toThrow()
208+
209+
expect(handler["client"].models.generateContentStream).toHaveBeenCalledTimes(1)
210+
})
105211
})
106212

107213
describe("completePrompt", () => {
@@ -134,14 +240,77 @@ describe("GeminiHandler", () => {
134240
)
135241
})
136242

137-
it("should handle empty response", async () => {
243+
it("should handle empty response with error", async () => {
138244
// Mock the response with empty text
139245
;(handler["client"].models.generateContent as any).mockResolvedValue({
140246
text: "",
141247
})
142248

249+
await expect(handler.completePrompt("Test prompt")).rejects.toThrow(
250+
t("common:errors.gemini.generate_complete_prompt", {
251+
error: "Received blank response from Gemini API",
252+
}),
253+
)
254+
})
255+
256+
it("should retry on rate limit and succeed", async () => {
257+
const mockError = new Error("Rate limit exceeded")
258+
// @ts-ignore - adding status property to error
259+
mockError.status = 429
260+
261+
const mockResponse = {
262+
text: "Success after rate limit",
263+
candidates: [],
264+
}
265+
266+
const generateContentMock = handler["client"].models.generateContent as any
267+
generateContentMock.mockRejectedValueOnce(mockError).mockResolvedValueOnce(mockResponse)
268+
143269
const result = await handler.completePrompt("Test prompt")
144-
expect(result).toBe("")
270+
271+
expect(generateContentMock).toHaveBeenCalledTimes(2)
272+
expect(result).toBe("Success after rate limit")
273+
})
274+
275+
it("should handle network errors with retry", async () => {
276+
const mockError = new Error("Network timeout")
277+
278+
const mockResponse = {
279+
text: "Success after network error",
280+
candidates: [],
281+
}
282+
283+
const generateContentMock = handler["client"].models.generateContent as any
284+
generateContentMock.mockRejectedValueOnce(mockError).mockResolvedValueOnce(mockResponse)
285+
286+
const result = await handler.completePrompt("Test prompt")
287+
288+
expect(generateContentMock).toHaveBeenCalledTimes(2)
289+
expect(result).toBe("Success after network error")
290+
})
291+
292+
it("should respect retry delay from error details", async () => {
293+
const mockError: any = new Error("Rate limit exceeded")
294+
mockError.status = 429
295+
mockError.errorDetails = [
296+
{
297+
"@type": "type.googleapis.com/google.rpc.RetryInfo",
298+
retryDelay: "5s",
299+
},
300+
]
301+
302+
const mockResponse = {
303+
text: "Success with custom retry delay",
304+
candidates: [],
305+
}
306+
307+
const generateContentMock = handler["client"].models.generateContent as any
308+
generateContentMock.mockRejectedValueOnce(mockError).mockResolvedValueOnce(mockResponse)
309+
310+
const result = await handler.completePrompt("Test prompt")
311+
312+
expect(generateContentMock).toHaveBeenCalledTimes(2)
313+
expect(result).toBe("Success with custom retry delay")
145314
})
146315
})
147316

0 commit comments

Comments
 (0)