Skip to content

Commit 27941cf

Browse files
authored
test: provider error classification — overflow detection and message extraction (#375)
ProviderError.parseStreamError and parseAPICallError had zero test coverage despite being on the critical error-handling path for every LLM API failure. Wrong classification causes crashes instead of graceful compaction, or incorrect retry behavior. These 18 tests cover SSE error codes, overflow regex patterns across 6+ providers, HTTP 413 detection, OpenAI 404 retry override, HTML response handling, and status code fallback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> https://claude.ai/code/session_01XDr623NsEC3PHzfgiQVPUh
1 parent b482df9 commit 27941cf

File tree

1 file changed

+262
-0
lines changed

1 file changed

+262
-0
lines changed
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import { describe, test, expect } from "bun:test"
2+
import { ProviderError } from "../../src/provider/error"
3+
import { APICallError } from "ai"
4+
5+
// Helper to construct APICallError instances for testing.
6+
// APICallError is from the Vercel AI SDK and wraps HTTP errors from LLM providers.
7+
function makeAPICallError(opts: {
8+
message?: string
9+
statusCode?: number
10+
responseBody?: string
11+
isRetryable?: boolean
12+
url?: string
13+
responseHeaders?: Record<string, string>
14+
}): APICallError {
15+
return new APICallError({
16+
message: opts.message ?? "",
17+
statusCode: opts.statusCode,
18+
responseBody: opts.responseBody,
19+
isRetryable: opts.isRetryable ?? false,
20+
url: opts.url,
21+
responseHeaders: opts.responseHeaders,
22+
requestBodyValues: {},
23+
})
24+
}
25+
26+
// ---------------------------------------------------------------------------
27+
// parseStreamError — classifies SSE streaming errors from providers
28+
// ---------------------------------------------------------------------------
29+
describe("ProviderError.parseStreamError: SSE error classification", () => {
30+
test("classifies context_length_exceeded as context_overflow", () => {
31+
const result = ProviderError.parseStreamError({
32+
type: "error",
33+
error: { code: "context_length_exceeded", message: "too long" },
34+
})
35+
expect(result).toBeDefined()
36+
expect(result!.type).toBe("context_overflow")
37+
})
38+
39+
test("classifies usage_not_included with upgrade URL", () => {
40+
const result = ProviderError.parseStreamError({
41+
type: "error",
42+
error: { code: "usage_not_included", message: "not available" },
43+
})
44+
expect(result).toBeDefined()
45+
expect(result!.type).toBe("api_error")
46+
if (result!.type === "api_error") {
47+
expect(result!.message).toContain("chatgpt.com/explore/plus")
48+
}
49+
})
50+
51+
test("classifies invalid_prompt with passthrough message", () => {
52+
const result = ProviderError.parseStreamError({
53+
type: "error",
54+
error: { code: "invalid_prompt", message: "Your prompt contains disallowed content" },
55+
})
56+
expect(result).toBeDefined()
57+
expect(result!.type).toBe("api_error")
58+
if (result!.type === "api_error") {
59+
expect(result!.message).toBe("Your prompt contains disallowed content")
60+
}
61+
})
62+
63+
test("invalid_prompt falls back to default when message is not a string", () => {
64+
const result = ProviderError.parseStreamError({
65+
type: "error",
66+
error: { code: "invalid_prompt", message: 42 },
67+
})
68+
expect(result).toBeDefined()
69+
expect(result!.type).toBe("api_error")
70+
if (result!.type === "api_error") {
71+
expect(result!.message).toBe("Invalid prompt.")
72+
}
73+
})
74+
75+
test("returns undefined for non-error events", () => {
76+
expect(ProviderError.parseStreamError({ type: "content", text: "hello" })).toBeUndefined()
77+
})
78+
79+
test("returns undefined for unknown error codes", () => {
80+
expect(
81+
ProviderError.parseStreamError({
82+
type: "error",
83+
error: { code: "unknown_code", message: "weird" },
84+
}),
85+
).toBeUndefined()
86+
})
87+
88+
test("returns undefined for null/undefined input", () => {
89+
expect(ProviderError.parseStreamError(null)).toBeUndefined()
90+
expect(ProviderError.parseStreamError(undefined)).toBeUndefined()
91+
})
92+
93+
test("parses JSON string input (AI SDK sometimes passes SSE chunks as strings)", () => {
94+
const jsonStr = JSON.stringify({
95+
type: "error",
96+
error: { code: "context_length_exceeded" },
97+
})
98+
const result = ProviderError.parseStreamError(jsonStr)
99+
expect(result).toBeDefined()
100+
expect(result!.type).toBe("context_overflow")
101+
102+
// Non-JSON strings return undefined
103+
expect(ProviderError.parseStreamError("not valid json")).toBeUndefined()
104+
})
105+
})
106+
107+
// ---------------------------------------------------------------------------
108+
// parseAPICallError — classifies HTTP errors from LLM provider APIs.
109+
// Overflow detection does NOT depend on providerID; it uses regex matching
110+
// on the error message. providerID only affects retry logic.
111+
// ---------------------------------------------------------------------------
112+
describe("ProviderError.parseAPICallError: overflow detection", () => {
113+
test("detects 'prompt is too long' pattern (Anthropic)", () => {
114+
const result = ProviderError.parseAPICallError({
115+
providerID: "anthropic" as any,
116+
error: makeAPICallError({
117+
message: "prompt is too long: 200000 tokens > 100000 maximum",
118+
statusCode: 400,
119+
}),
120+
})
121+
expect(result.type).toBe("context_overflow")
122+
})
123+
124+
test("detects 'exceeds the context window' pattern (OpenAI)", () => {
125+
const result = ProviderError.parseAPICallError({
126+
providerID: "openai" as any,
127+
error: makeAPICallError({
128+
message: "This request exceeds the context window for gpt-4o",
129+
statusCode: 400,
130+
}),
131+
})
132+
expect(result.type).toBe("context_overflow")
133+
})
134+
135+
test("detects HTTP 413 as overflow regardless of message text", () => {
136+
const result = ProviderError.parseAPICallError({
137+
providerID: "anthropic" as any,
138+
error: makeAPICallError({
139+
message: "something completely unrelated",
140+
statusCode: 413,
141+
}),
142+
})
143+
expect(result.type).toBe("context_overflow")
144+
})
145+
146+
test("detects '400 (no body)' and '413 (no body)' patterns (Cerebras/Mistral)", () => {
147+
for (const msg of ["400 (no body)", "413 (no body)", "400 status code (no body)"]) {
148+
const result = ProviderError.parseAPICallError({
149+
providerID: "cerebras" as any,
150+
error: makeAPICallError({ message: msg, statusCode: 400 }),
151+
})
152+
expect(result.type).toBe("context_overflow")
153+
}
154+
})
155+
})
156+
157+
describe("ProviderError.parseAPICallError: error message extraction", () => {
158+
test("OpenAI 404 is treated as retryable (model may be temporarily unavailable)", () => {
159+
const result = ProviderError.parseAPICallError({
160+
providerID: "openai" as any,
161+
error: makeAPICallError({
162+
message: "Model not found",
163+
statusCode: 404,
164+
isRetryable: false, // SDK says not retryable, but our code overrides
165+
}),
166+
})
167+
expect(result.type).toBe("api_error")
168+
if (result.type === "api_error") {
169+
expect(result.isRetryable).toBe(true)
170+
}
171+
})
172+
173+
test("non-OpenAI providers pass through isRetryable from SDK", () => {
174+
const retriable = ProviderError.parseAPICallError({
175+
providerID: "anthropic" as any,
176+
error: makeAPICallError({
177+
message: "Internal server error",
178+
statusCode: 500,
179+
isRetryable: true,
180+
}),
181+
})
182+
expect(retriable.type).toBe("api_error")
183+
if (retriable.type === "api_error") expect(retriable.isRetryable).toBe(true)
184+
185+
const nonRetriable = ProviderError.parseAPICallError({
186+
providerID: "anthropic" as any,
187+
error: makeAPICallError({
188+
message: "Bad request",
189+
statusCode: 400,
190+
isRetryable: false,
191+
}),
192+
})
193+
expect(nonRetriable.type).toBe("api_error")
194+
if (nonRetriable.type === "api_error") expect(nonRetriable.isRetryable).toBe(false)
195+
})
196+
197+
test("HTML 403 response yields human-readable gateway message", () => {
198+
// When the SDK provides a message AND the response body is HTML,
199+
// the code detects the HTML and returns a friendly message instead of raw markup.
200+
const result = ProviderError.parseAPICallError({
201+
providerID: "anthropic" as any,
202+
error: makeAPICallError({
203+
message: "Forbidden",
204+
statusCode: 403,
205+
responseBody: "<html><body>Forbidden</body></html>",
206+
}),
207+
})
208+
expect(result.type).toBe("api_error")
209+
if (result.type === "api_error") {
210+
expect(result.message).toContain("Forbidden")
211+
expect(result.message).toContain("gateway or proxy")
212+
}
213+
})
214+
215+
test("preserves URL in metadata when present", () => {
216+
const result = ProviderError.parseAPICallError({
217+
providerID: "anthropic" as any,
218+
error: makeAPICallError({
219+
message: "Bad request",
220+
statusCode: 400,
221+
url: "https://api.anthropic.com/v1/messages",
222+
}),
223+
})
224+
expect(result.type).toBe("api_error")
225+
if (result.type === "api_error") {
226+
expect(result.metadata?.url).toBe("https://api.anthropic.com/v1/messages")
227+
}
228+
})
229+
230+
test("falls back to HTTP status text when message is empty and no body", () => {
231+
// Many providers send empty messages on rate-limiting (429);
232+
// the code falls back to Node's STATUS_CODES lookup.
233+
const result = ProviderError.parseAPICallError({
234+
providerID: "anthropic" as any,
235+
error: makeAPICallError({
236+
message: "",
237+
statusCode: 429,
238+
}),
239+
})
240+
expect(result.type).toBe("api_error")
241+
if (result.type === "api_error") {
242+
expect(result.message).toBe("Too Many Requests")
243+
}
244+
})
245+
246+
test("appends plain-text responseBody to message", () => {
247+
// When responseBody is not JSON and not HTML, it's appended to the status message
248+
const result = ProviderError.parseAPICallError({
249+
providerID: "anthropic" as any,
250+
error: makeAPICallError({
251+
message: "Bad Request",
252+
statusCode: 400,
253+
responseBody: "invalid JSON in request body at position 42",
254+
}),
255+
})
256+
expect(result.type).toBe("api_error")
257+
if (result.type === "api_error") {
258+
expect(result.message).toContain("Bad Request")
259+
expect(result.message).toContain("invalid JSON in request body")
260+
}
261+
})
262+
})

0 commit comments

Comments
 (0)