c85d2f9bc8
OpenAI streaming responses can surface mid-stream errors with type 'server_error' or with the prose message 'An error occurred while processing your request'. Neither matched any entry in RETRYABLE_MESSAGE_PATTERNS, so shouldRetryError returned false and the runtime-fallback / fallback-retry code paths skipped retry. The result was that GPT-5.5 subagent (and main) turns silently stalled until the stale timeout fired. The maintainer's diagnosis on issue #3799 explicitly recommends adding these two patterns to model-error-classifier.ts; this commit does exactly that and adds two regression tests covering the JSON envelope and the prose form.
468 lines
12 KiB
TypeScript
468 lines
12 KiB
TypeScript
declare const require: (name: string) => any
|
|
const { describe, expect, test, beforeEach, afterEach, mock, spyOn } = require("bun:test")
|
|
import * as connectedProvidersCache from "./connected-providers-cache"
|
|
|
|
let readConnectedProvidersCacheSpy: ReturnType<typeof spyOn> | undefined
|
|
const { shouldRetryError, selectFallbackProvider, isRetryableModelError } = await import("./model-error-classifier")
|
|
|
|
describe("model-error-classifier", () => {
|
|
beforeEach(() => {
|
|
readConnectedProvidersCacheSpy?.mockRestore()
|
|
readConnectedProvidersCacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
|
})
|
|
|
|
afterEach(() => {
|
|
readConnectedProvidersCacheSpy?.mockRestore()
|
|
readConnectedProvidersCacheSpy = undefined
|
|
})
|
|
|
|
test("treats overloaded retry messages as retryable", () => {
|
|
//#given
|
|
const error = { message: "Provider is overloaded" }
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
test("treats cooling-down auto-retry messages as retryable", () => {
|
|
//#given
|
|
const error = {
|
|
message:
|
|
"All credentials for model claude-opus-4-7-thinking are cooling down [retrying in ~5 days attempt #1]",
|
|
}
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
test("selectFallbackProvider prefers first connected provider in preference order", () => {
|
|
//#given
|
|
readConnectedProvidersCacheSpy?.mockReturnValue(["anthropic", "nvidia"])
|
|
|
|
//#when
|
|
const provider = selectFallbackProvider(["anthropic", "nvidia"], "nvidia")
|
|
|
|
//#then
|
|
expect(provider).toBe("anthropic")
|
|
})
|
|
|
|
test("selectFallbackProvider falls back to next connected provider when first is disconnected", () => {
|
|
//#given
|
|
readConnectedProvidersCacheSpy?.mockReturnValue(["nvidia"])
|
|
|
|
//#when
|
|
const provider = selectFallbackProvider(["anthropic", "nvidia"])
|
|
|
|
//#then
|
|
expect(provider).toBe("nvidia")
|
|
})
|
|
|
|
test("selectFallbackProvider uses provider preference order when cache is missing", () => {
|
|
//#given - no cache file
|
|
|
|
//#when
|
|
const provider = selectFallbackProvider(["anthropic", "nvidia"], "nvidia")
|
|
|
|
//#then
|
|
expect(provider).toBe("anthropic")
|
|
})
|
|
|
|
test("selectFallbackProvider uses connected preferred provider when fallback providers are unavailable", () => {
|
|
//#given
|
|
readConnectedProvidersCacheSpy?.mockReturnValue(["provider-x"])
|
|
|
|
//#when
|
|
const provider = selectFallbackProvider(["provider-y"], "provider-x")
|
|
|
|
//#then
|
|
expect(provider).toBe("provider-x")
|
|
})
|
|
|
|
test("treats QuotaExceededError (PascalCase name) as non-retryable STOP error", () => {
|
|
//#given
|
|
const error = { name: "QuotaExceededError" }
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("treats quotaexceedederror (lowercase name) as non-retryable STOP error", () => {
|
|
//#given
|
|
const error = { name: "quotaexceedederror" }
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("treats InsufficientCreditsError (PascalCase name) as non-retryable STOP error", () => {
|
|
//#given
|
|
const error = { name: "InsufficientCreditsError" }
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("treats insufficientcreditserror (lowercase name) as non-retryable STOP error", () => {
|
|
//#given
|
|
const error = { name: "insufficientcreditserror" }
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("treats FreeUsageLimitError (PascalCase name) as non-retryable STOP error", () => {
|
|
//#given
|
|
const error = { name: "FreeUsageLimitError" }
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("treats freeusagelimiterror (lowercase name) as non-retryable STOP error", () => {
|
|
//#given
|
|
const error = { name: "freeusagelimiterror" }
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("treats quota reset message as non-retryable STOP error (no error name)", () => {
|
|
//#given
|
|
const error = { message: "quota will reset after 1 hour" }
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("treats quota exceeded message as non-retryable STOP error (no error name)", () => {
|
|
//#given
|
|
const error = { message: "quota exceeded for this billing period" }
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("treats usage limit reached message as non-retryable STOP error (no error name)", () => {
|
|
//#given
|
|
const error = { message: "usage limit has been reached for your account" }
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("treats insufficient credits message as non-retryable STOP error (no error name)", () => {
|
|
//#given
|
|
const error = { message: "insufficient credits to complete this request" }
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("treats 'bad request' message as retryable (GitHub Copilot rolling update)", () => {
|
|
//#given
|
|
const error = { message: "400 Bad Request" }
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
test("treats 'bad request' lowercase as retryable", () => {
|
|
//#given
|
|
const error = { message: "bad request: model temporarily unavailable" }
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
test("treats localized transient provider messages as retryable", () => {
|
|
//#given
|
|
const errors = [
|
|
{ message: "请求过于频繁,请稍后重试" },
|
|
{ message: "服务暂时不可用" },
|
|
{ message: "触发频率限制" },
|
|
]
|
|
|
|
//#when
|
|
const results = errors.map((error) => shouldRetryError(error))
|
|
|
|
//#then
|
|
expect(results).toEqual([true, true, true])
|
|
})
|
|
|
|
test("treats subscription quota message as non-retryable", () => {
|
|
//#given
|
|
const error = { message: "Subscription quota exceeded. You can continue using free models." }
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("treats localized quota exhaustion messages as non-retryable stop errors", () => {
|
|
//#given
|
|
const errors = [
|
|
{ message: "已达到 5 小时的使用上限" },
|
|
{ message: "额度不足" },
|
|
{ message: "账户余额不足" },
|
|
{ message: "免费额度已耗尽" },
|
|
]
|
|
|
|
//#when
|
|
const results = errors.map((error) => shouldRetryError(error))
|
|
|
|
//#then
|
|
expect(results).toEqual([false, false, false, false])
|
|
})
|
|
|
|
test("treats HTTP 429 rate limit message as retryable", () => {
|
|
//#given
|
|
const error = { message: "429 Too Many Requests: rate limit reached" }
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
test("treats forbidden provider message as retryable", () => {
|
|
//#given
|
|
const error = { message: "Forbidden: Selected provider is forbidden" }
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
test("does not treat unrelated forbidden messages as retryable", () => {
|
|
//#given
|
|
const error = { message: "EACCES: forbidden write to /etc/hosts" }
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("does not treat unrelated 403 messages as retryable", () => {
|
|
//#given
|
|
const error = { message: "Tool returned HTTP 403 for the requested URL" }
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("GLM 429 rate limit with statusCode and Chinese message triggers fallback (statusCode check)", () => {
|
|
//#given
|
|
const error = { statusCode: 429, message: "请求频率过高" }
|
|
|
|
//#when
|
|
const result = isRetryableModelError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
test("GLM 429 rate limit with statusCode and no message at all triggers fallback", () => {
|
|
//#given
|
|
const error = { statusCode: 429 }
|
|
|
|
//#when
|
|
const result = isRetryableModelError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
test("GLM 503 service unavailable with statusCode triggers fallback", () => {
|
|
//#given
|
|
const error = { statusCode: 503, message: "Service Unavailable" }
|
|
|
|
//#when
|
|
const result = isRetryableModelError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
test("GLM 529 overloaded with statusCode triggers fallback", () => {
|
|
//#given
|
|
const error = { statusCode: 529 }
|
|
|
|
//#when
|
|
const result = isRetryableModelError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
test("HTTP 400 with statusCode does NOT trigger fallback via statusCode alone (400 excluded)", () => {
|
|
//#given — message does NOT match any retryable pattern
|
|
const error = { statusCode: 400, message: "Invalid parameter: model_name" }
|
|
|
|
//#when
|
|
const result = isRetryableModelError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("HTTP 401 with statusCode does NOT trigger fallback (not a rate limit)", () => {
|
|
//#given
|
|
const error = { statusCode: 401, message: "Unauthorized" }
|
|
|
|
//#when
|
|
const result = isRetryableModelError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("GLM code 1304 daily quota 429 does NOT trigger fallback (STOP pattern wins)", () => {
|
|
//#given
|
|
const error = {
|
|
statusCode: 429,
|
|
message: "Daily call limit for this API key has been reached. Limit will reset at midnight UTC.",
|
|
}
|
|
|
|
//#when
|
|
const result = isRetryableModelError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("GLM account in arrears 429 does NOT trigger fallback (STOP pattern wins)", () => {
|
|
//#given
|
|
const error = {
|
|
statusCode: 429,
|
|
message: "Your account is in arrears, please recharge and try again.",
|
|
}
|
|
|
|
//#when
|
|
const result = isRetryableModelError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("GLM fair use policy violation 429 does NOT trigger fallback (STOP pattern wins)", () => {
|
|
//#given
|
|
const error = {
|
|
statusCode: 429,
|
|
message: "Request blocked under Fair Use Policy. Your request rate has been restricted.",
|
|
}
|
|
|
|
//#when
|
|
const result = isRetryableModelError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("STOP message pattern takes precedence over 429 statusCode", () => {
|
|
//#given
|
|
const error = {
|
|
statusCode: 429,
|
|
message: "quota exceeded for this account, usage limit has been reached",
|
|
}
|
|
|
|
//#when
|
|
const result = isRetryableModelError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("rate limit message without statusCode still works (backward compat)", () => {
|
|
//#given
|
|
const error = { message: "rate limit reached for requests" }
|
|
|
|
//#when
|
|
const result = isRetryableModelError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
test("treats OpenAI streaming server_error envelopes as retryable (issue #3799)", () => {
|
|
//#given: OpenAI surfaces its mid-stream error with type 'server_error'
|
|
const error = {
|
|
name: undefined,
|
|
message: "{\"error\":{\"type\":\"server_error\",\"message\":\"server_error\"}}",
|
|
}
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
test("treats the OpenAI prose 'An error occurred while processing' message as retryable (issue #3799)", () => {
|
|
//#given: the human-readable prose surfaced when OpenAI's stream fails
|
|
const error = {
|
|
name: undefined,
|
|
message: "An error occurred while processing your request. Please try again later.",
|
|
}
|
|
|
|
//#when
|
|
const result = shouldRetryError(error)
|
|
|
|
//#then
|
|
expect(result).toBe(true)
|
|
})
|
|
})
|
|
|
|
export {}
|