The test name claimed it exercised RETRYABLE_ERROR_PATTERNS directly, but classifyErrorType actually matches 'payment required' via the quota_exceeded path first. Rename to 'detects payment required errors as retryable' to accurately describe end-to-end behavior.
266 lines
7.2 KiB
TypeScript
266 lines
7.2 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
|
|
import { classifyErrorType, extractAutoRetrySignal, extractStatusCode, isRetryableError } from "./error-classifier"
|
|
|
|
describe("runtime-fallback error classifier", () => {
|
|
test("detects cooling-down auto-retry status signals", () => {
|
|
//#given
|
|
const info = {
|
|
status:
|
|
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~5 days attempt #1]",
|
|
}
|
|
|
|
//#when
|
|
const signal = extractAutoRetrySignal(info)
|
|
|
|
//#then
|
|
expect(signal).toBeDefined()
|
|
})
|
|
|
|
test("detects single-word cooldown auto-retry status signals", () => {
|
|
//#given
|
|
const info = {
|
|
status:
|
|
"All credentials for model claude-opus-4-6 are cooldown [retrying in 7m 56s attempt #1]",
|
|
}
|
|
|
|
//#when
|
|
const signal = extractAutoRetrySignal(info)
|
|
|
|
//#then
|
|
expect(signal).toBeDefined()
|
|
})
|
|
|
|
test("treats cooling-down retry messages as retryable", () => {
|
|
//#given
|
|
const error = {
|
|
message:
|
|
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~5 days attempt #1]",
|
|
}
|
|
|
|
//#when
|
|
const retryable = isRetryableError(error, [400, 403, 408, 429, 500, 502, 503, 504, 529])
|
|
|
|
//#then
|
|
expect(retryable).toBe(true)
|
|
})
|
|
|
|
test("classifies ProviderModelNotFoundError as model_not_found", () => {
|
|
//#given
|
|
const error = {
|
|
name: "ProviderModelNotFoundError",
|
|
data: {
|
|
providerID: "anthropic",
|
|
modelID: "claude-opus-4-6",
|
|
message: "Model not found: anthropic/claude-opus-4-6.",
|
|
},
|
|
}
|
|
|
|
//#when
|
|
const errorType = classifyErrorType(error)
|
|
const retryable = isRetryableError(error, [429, 503, 529])
|
|
|
|
//#then
|
|
expect(errorType).toBe("model_not_found")
|
|
expect(retryable).toBe(true)
|
|
})
|
|
|
|
test("classifies nested AI_LoadAPIKeyError as missing_api_key", () => {
|
|
//#given
|
|
const error = {
|
|
data: {
|
|
name: "AI_LoadAPIKeyError",
|
|
message:
|
|
"Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
|
|
},
|
|
}
|
|
|
|
//#when
|
|
const errorType = classifyErrorType(error)
|
|
const retryable = isRetryableError(error, [429, 503, 529])
|
|
|
|
//#then
|
|
expect(errorType).toBe("missing_api_key")
|
|
expect(retryable).toBe(true)
|
|
})
|
|
|
|
test("ignores non-retry assistant status text", () => {
|
|
//#given
|
|
const info = {
|
|
status: "Thinking...",
|
|
}
|
|
|
|
//#when
|
|
const signal = extractAutoRetrySignal(info)
|
|
|
|
//#then
|
|
expect(signal).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe("extractStatusCode", () => {
|
|
test("extracts numeric statusCode from top-level", () => {
|
|
expect(extractStatusCode({ statusCode: 429 })).toBe(429)
|
|
})
|
|
|
|
test("extracts numeric status from top-level", () => {
|
|
expect(extractStatusCode({ status: 503 })).toBe(503)
|
|
})
|
|
|
|
test("extracts statusCode from nested data", () => {
|
|
expect(extractStatusCode({ data: { statusCode: 500 } })).toBe(500)
|
|
})
|
|
|
|
test("extracts statusCode from nested error", () => {
|
|
expect(extractStatusCode({ error: { statusCode: 502 } })).toBe(502)
|
|
})
|
|
|
|
test("extracts statusCode from nested cause", () => {
|
|
expect(extractStatusCode({ cause: { statusCode: 504 } })).toBe(504)
|
|
})
|
|
|
|
test("skips non-numeric status and finds deeper numeric statusCode", () => {
|
|
//#given — status is a string, but error.statusCode is numeric
|
|
const error = {
|
|
status: "error",
|
|
error: { statusCode: 429 },
|
|
}
|
|
|
|
//#when
|
|
const code = extractStatusCode(error)
|
|
|
|
//#then
|
|
expect(code).toBe(429)
|
|
})
|
|
|
|
test("skips non-numeric statusCode string and finds numeric in cause", () => {
|
|
const error = {
|
|
statusCode: "UNKNOWN",
|
|
status: "failed",
|
|
cause: { statusCode: 503 },
|
|
}
|
|
|
|
expect(extractStatusCode(error)).toBe(503)
|
|
})
|
|
|
|
test("returns undefined when no numeric status exists", () => {
|
|
expect(extractStatusCode({ status: "error", message: "something broke" })).toBeUndefined()
|
|
})
|
|
|
|
test("returns undefined for null/undefined error", () => {
|
|
expect(extractStatusCode(null)).toBeUndefined()
|
|
expect(extractStatusCode(undefined)).toBeUndefined()
|
|
})
|
|
|
|
test("falls back to regex match in error message", () => {
|
|
const error = { message: "Request failed with status code 429" }
|
|
expect(extractStatusCode(error, [429, 503])).toBe(429)
|
|
})
|
|
|
|
test("prefers top-level numeric over nested numeric", () => {
|
|
const error = {
|
|
statusCode: 400,
|
|
error: { statusCode: 429 },
|
|
cause: { statusCode: 503 },
|
|
}
|
|
expect(extractStatusCode(error)).toBe(400)
|
|
})
|
|
})
|
|
|
|
describe("quota error detection (fixes #2747)", () => {
|
|
test("classifies prettified subscription quota error as quota_exceeded", () => {
|
|
//#given
|
|
const error = {
|
|
name: "AI_APICallError",
|
|
message: "Subscription quota exceeded. You can continue using free models.",
|
|
}
|
|
|
|
//#when
|
|
const errorType = classifyErrorType(error)
|
|
const retryable = isRetryableError(error, [402, 429, 500, 502, 503, 504])
|
|
|
|
//#then
|
|
expect(errorType).toBe("quota_exceeded")
|
|
expect(retryable).toBe(true)
|
|
})
|
|
|
|
test("classifies billing hard limit error as quota_exceeded", () => {
|
|
//#given
|
|
const error = { message: "You have reached your billing hard limit." }
|
|
|
|
//#when
|
|
const errorType = classifyErrorType(error)
|
|
|
|
//#then
|
|
expect(errorType).toBe("quota_exceeded")
|
|
})
|
|
|
|
test("classifies exhausted capacity error as quota_exceeded", () => {
|
|
//#given
|
|
const error = { message: "You have exhausted your capacity on this model." }
|
|
|
|
//#when
|
|
const errorType = classifyErrorType(error)
|
|
|
|
//#then
|
|
expect(errorType).toBe("quota_exceeded")
|
|
})
|
|
|
|
test("classifies out of credits error as quota_exceeded", () => {
|
|
//#given
|
|
const error = { message: "Out of credits. Please add more credits to continue." }
|
|
|
|
//#when
|
|
const errorType = classifyErrorType(error)
|
|
|
|
//#then
|
|
expect(errorType).toBe("quota_exceeded")
|
|
})
|
|
|
|
test("treats HTTP 402 Payment Required as retryable", () => {
|
|
//#given
|
|
const error = { statusCode: 402, message: "Payment Required" }
|
|
|
|
//#when
|
|
const retryable = isRetryableError(error, [402, 429, 500, 502, 503, 504])
|
|
|
|
//#then
|
|
expect(retryable).toBe(true)
|
|
})
|
|
|
|
test("matches subscription quota pattern in RETRYABLE_ERROR_PATTERNS", () => {
|
|
//#given
|
|
const error = { message: "Subscription quota exceeded. You can continue using free models." }
|
|
|
|
//#when
|
|
const retryable = isRetryableError(error, [429, 503])
|
|
|
|
//#then
|
|
expect(retryable).toBe(true)
|
|
})
|
|
|
|
test("classifies QuotaExceededError by errorName even without quota keywords in message", () => {
|
|
//#given
|
|
const error = { name: "QuotaExceededError", message: "Request failed." }
|
|
|
|
//#when
|
|
const errorType = classifyErrorType(error)
|
|
|
|
//#then
|
|
expect(errorType).toBe("quota_exceeded")
|
|
})
|
|
|
|
test("detects payment required errors as retryable", () => {
|
|
//#given
|
|
const error = { message: "Error 402: payment required for this request" }
|
|
|
|
//#when
|
|
const errorType = classifyErrorType(error)
|
|
const retryable = isRetryableError(error, [429, 503])
|
|
|
|
//#then
|
|
expect(errorType).toBe("quota_exceeded")
|
|
expect(retryable).toBe(true)
|
|
})
|
|
})
|