Skip to content

Commit 9edfe46

Browse files
committed
fix: prevent task corruption during API retry cycles
1 parent 0028c56 commit 9edfe46

File tree

8 files changed

+1616
-53
lines changed

8 files changed

+1616
-53
lines changed
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
import { describe, test, expect } from "vitest"
2+
import { UnifiedErrorHandler, ErrorContext } from "./UnifiedErrorHandler"
3+
4+
describe("UnifiedErrorHandler", () => {
5+
const createContext = (overrides: Partial<ErrorContext> = {}): ErrorContext => ({
6+
isStreaming: false,
7+
provider: "anthropic",
8+
modelId: "claude-3-sonnet",
9+
retryAttempt: 0,
10+
requestId: "test-request",
11+
...overrides,
12+
})
13+
14+
describe("error classification", () => {
15+
test("classifies HTTP 429 as THROTTLING", () => {
16+
const error = { status: 429, message: "Rate limit exceeded" }
17+
const context = createContext()
18+
19+
const result = UnifiedErrorHandler.handle(error, context)
20+
expect(result.errorType).toBe("THROTTLING")
21+
expect(result.shouldRetry).toBe(true)
22+
})
23+
24+
test("classifies ThrottlingException as THROTTLING", () => {
25+
const error = { name: "ThrottlingException", message: "Request was throttled" }
26+
const context = createContext()
27+
28+
const result = UnifiedErrorHandler.handle(error, context)
29+
expect(result.errorType).toBe("THROTTLING")
30+
expect(result.shouldRetry).toBe(true)
31+
})
32+
33+
test("classifies AccessDeniedException as ACCESS_DENIED", () => {
34+
const error = { name: "AccessDeniedException", message: "Access denied" }
35+
const context = createContext()
36+
37+
const result = UnifiedErrorHandler.handle(error, context)
38+
expect(result.errorType).toBe("ACCESS_DENIED")
39+
expect(result.shouldRetry).toBe(false)
40+
expect(result.shouldThrow).toBe(true)
41+
})
42+
43+
test("classifies ResourceNotFoundException as NOT_FOUND", () => {
44+
const error = { name: "ResourceNotFoundException", message: "Resource not found" }
45+
const context = createContext()
46+
47+
const result = UnifiedErrorHandler.handle(error, context)
48+
expect(result.errorType).toBe("NOT_FOUND")
49+
expect(result.shouldRetry).toBe(false)
50+
expect(result.shouldThrow).toBe(true)
51+
})
52+
53+
test("classifies ServiceUnavailableException as SERVICE_UNAVAILABLE", () => {
54+
const error = { name: "ServiceUnavailableException", message: "Service unavailable" }
55+
const context = createContext()
56+
57+
const result = UnifiedErrorHandler.handle(error, context)
58+
expect(result.errorType).toBe("SERVICE_UNAVAILABLE")
59+
expect(result.shouldRetry).toBe(true)
60+
})
61+
62+
test("classifies ValidationException as INVALID_REQUEST", () => {
63+
const error = { name: "ValidationException", message: "Invalid request" }
64+
const context = createContext()
65+
66+
const result = UnifiedErrorHandler.handle(error, context)
67+
expect(result.errorType).toBe("INVALID_REQUEST")
68+
expect(result.shouldRetry).toBe(false)
69+
expect(result.shouldThrow).toBe(true)
70+
})
71+
72+
test("classifies throttling patterns in message", () => {
73+
const error = new Error("too many requests, please wait")
74+
const context = createContext()
75+
76+
const result = UnifiedErrorHandler.handle(error, context)
77+
expect(result.errorType).toBe("THROTTLING")
78+
expect(result.shouldRetry).toBe(true)
79+
})
80+
81+
test("classifies rate limit patterns in message", () => {
82+
const error = new Error("rate limit exceeded, please wait")
83+
const context = createContext()
84+
85+
const result = UnifiedErrorHandler.handle(error, context)
86+
expect(result.errorType).toBe("RATE_LIMITED")
87+
expect(result.shouldRetry).toBe(true)
88+
})
89+
90+
test("classifies quota patterns in message", () => {
91+
const error = new Error("quota exceeded for this month")
92+
const context = createContext()
93+
94+
const result = UnifiedErrorHandler.handle(error, context)
95+
expect(result.errorType).toBe("QUOTA_EXCEEDED")
96+
expect(result.shouldRetry).toBe(true)
97+
})
98+
99+
test("classifies network errors", () => {
100+
const error = new Error("network connection failed")
101+
const context = createContext()
102+
103+
const result = UnifiedErrorHandler.handle(error, context)
104+
expect(result.errorType).toBe("NETWORK_ERROR")
105+
expect(result.shouldRetry).toBe(true)
106+
})
107+
108+
test("classifies timeout errors", () => {
109+
const error = new Error("request timed out")
110+
const context = createContext()
111+
112+
const result = UnifiedErrorHandler.handle(error, context)
113+
expect(result.errorType).toBe("TIMEOUT")
114+
expect(result.shouldRetry).toBe(true)
115+
})
116+
117+
test("classifies generic errors", () => {
118+
const error = new Error("something went wrong")
119+
const context = createContext()
120+
121+
const result = UnifiedErrorHandler.handle(error, context)
122+
expect(result.errorType).toBe("GENERIC")
123+
})
124+
125+
test("classifies unknown non-Error objects", () => {
126+
const error = "string error"
127+
const context = createContext()
128+
129+
const result = UnifiedErrorHandler.handle(error, context)
130+
expect(result.errorType).toBe("UNKNOWN")
131+
})
132+
})
133+
134+
describe("retry logic", () => {
135+
test("retries throttling errors up to max attempts", () => {
136+
const error = { status: 429, message: "Rate limit exceeded" }
137+
138+
// Should retry for first few attempts
139+
for (let attempt = 0; attempt < 5; attempt++) {
140+
const context = createContext({ retryAttempt: attempt })
141+
const result = UnifiedErrorHandler.handle(error, context)
142+
expect(result.shouldRetry).toBe(true)
143+
}
144+
145+
// Should not retry after max attempts
146+
const contextMaxAttempts = createContext({ retryAttempt: 5 })
147+
const resultMaxAttempts = UnifiedErrorHandler.handle(error, contextMaxAttempts)
148+
expect(resultMaxAttempts.shouldRetry).toBe(false)
149+
})
150+
151+
test("does not retry non-retryable errors", () => {
152+
const error = { name: "AccessDeniedException", message: "Access denied" }
153+
const context = createContext()
154+
155+
const result = UnifiedErrorHandler.handle(error, context)
156+
expect(result.shouldRetry).toBe(false)
157+
})
158+
159+
test("retries service unavailable errors", () => {
160+
const error = new Error("service temporarily unavailable")
161+
const context = createContext()
162+
163+
const result = UnifiedErrorHandler.handle(error, context)
164+
expect(result.shouldRetry).toBe(true)
165+
})
166+
})
167+
168+
describe("streaming context handling", () => {
169+
test("throws immediately for throttling in streaming context", () => {
170+
const error = { status: 429, message: "Rate limit exceeded" }
171+
const context = createContext({ isStreaming: true })
172+
173+
const result = UnifiedErrorHandler.handle(error, context)
174+
expect(result.shouldThrow).toBe(true)
175+
expect(result.shouldRetry).toBe(true) // Still retryable, but should throw for proper handling
176+
})
177+
178+
test("provides stream chunks for non-throwing streaming errors", () => {
179+
const error = new Error("generic error")
180+
const context = createContext({ isStreaming: true })
181+
182+
const result = UnifiedErrorHandler.handle(error, context)
183+
expect(result.streamChunks).toBeDefined()
184+
expect(result.streamChunks).toHaveLength(2)
185+
expect(result.streamChunks![0].type).toBe("text")
186+
expect(result.streamChunks![1].type).toBe("usage")
187+
})
188+
189+
test("does not provide stream chunks for non-streaming context", () => {
190+
const error = new Error("generic error")
191+
const context = createContext({ isStreaming: false })
192+
193+
const result = UnifiedErrorHandler.handle(error, context)
194+
expect(result.streamChunks).toBeUndefined()
195+
})
196+
})
197+
198+
describe("retry delay calculation", () => {
199+
test("calculates exponential backoff", () => {
200+
const error = new Error("generic error message")
201+
202+
const context0 = createContext({ retryAttempt: 0 })
203+
const result0 = UnifiedErrorHandler.handle(error, context0)
204+
expect(result0.retryDelay).toBe(5) // base delay
205+
206+
const context1 = createContext({ retryAttempt: 1 })
207+
const result1 = UnifiedErrorHandler.handle(error, context1)
208+
expect(result1.retryDelay).toBe(10) // 5 * 2^1
209+
210+
const context2 = createContext({ retryAttempt: 2 })
211+
const result2 = UnifiedErrorHandler.handle(error, context2)
212+
expect(result2.retryDelay).toBe(20) // 5 * 2^2
213+
})
214+
215+
test("respects maximum delay", () => {
216+
const error = new Error("service unavailable")
217+
const context = createContext({ retryAttempt: 10 }) // Very high retry attempt
218+
219+
const result = UnifiedErrorHandler.handle(error, context)
220+
expect(result.retryDelay).toBeLessThanOrEqual(600) // Max 10 minutes
221+
})
222+
223+
test("adjusts delay based on error type", () => {
224+
const baseRetryAttempt = 1
225+
226+
// Service unavailable gets longer delay
227+
const serviceError = { name: "ServiceUnavailableException", message: "Service unavailable" }
228+
const serviceContext = createContext({ retryAttempt: baseRetryAttempt })
229+
const serviceResult = UnifiedErrorHandler.handle(serviceError, serviceContext)
230+
231+
// Network error gets shorter delay
232+
const networkError = new Error("network connection failed")
233+
const networkContext = createContext({ retryAttempt: baseRetryAttempt })
234+
const networkResult = UnifiedErrorHandler.handle(networkError, networkContext)
235+
236+
expect(serviceResult.retryDelay).toBeGreaterThan(networkResult.retryDelay!)
237+
})
238+
239+
test("extracts provider-specific retry delay", () => {
240+
// Simulate Google Gemini retry info
241+
const error = {
242+
message: "Rate limit exceeded",
243+
errorDetails: [
244+
{
245+
"@type": "type.googleapis.com/google.rpc.RetryInfo",
246+
retryDelay: "30s",
247+
},
248+
],
249+
}
250+
const context = createContext()
251+
252+
const result = UnifiedErrorHandler.handle(error, context)
253+
expect(result.retryDelay).toBe(31) // 30s + 1s buffer
254+
})
255+
})
256+
257+
describe("error message formatting", () => {
258+
test("formats error message with context", () => {
259+
const error = new Error("Test error message")
260+
const context = createContext({
261+
provider: "anthropic",
262+
modelId: "claude-3-sonnet",
263+
retryAttempt: 2,
264+
})
265+
266+
const result = UnifiedErrorHandler.handle(error, context)
267+
expect(result.formattedMessage).toContain("[anthropic:claude-3-sonnet]")
268+
expect(result.formattedMessage).toContain("Test error message")
269+
expect(result.formattedMessage).toContain("(Retry 2)")
270+
})
271+
272+
test("includes error type in formatted message", () => {
273+
const error = { status: 429, message: "Rate limit exceeded" }
274+
const context = createContext()
275+
276+
const result = UnifiedErrorHandler.handle(error, context)
277+
expect(result.formattedMessage).toContain("[THROTTLING]")
278+
})
279+
280+
test("handles non-Error objects", () => {
281+
const error = { someProperty: "not an Error object" }
282+
const context = createContext()
283+
284+
const result = UnifiedErrorHandler.handle(error, context)
285+
expect(result.formattedMessage).toContain("Unknown error")
286+
})
287+
288+
test("cleans up whitespace in error messages", () => {
289+
const error = new Error("Error with extra whitespace")
290+
const context = createContext()
291+
292+
const result = UnifiedErrorHandler.handle(error, context)
293+
expect(result.formattedMessage).toContain("Error with extra whitespace")
294+
})
295+
})
296+
})

0 commit comments

Comments
 (0)