fix(oauth+errors): OAuth silent refresh, quota STOP patterns, compaction loop cap

Bug fixes:
1. OAuth token refresh (#3149): buildHttpRequestInit() now attempts silent refresh
   via refresh_token before triggering full browser re-auth. Added refresh() method
   to McpOAuthProvider. Includes test isolation fix for discovery mock.

2. Quota error STOP (#3126): Added STOP_MESSAGE_PATTERNS in model-error-classifier
   that take precedence over RETRYABLE_MESSAGE_PATTERNS. Message-only quota errors
   now non-retryable. Runtime-fallback: quota_exceeded with 'retrying in' signal
   still triggers fallback (provider-managed auto-retry). Restored removed patterns.

3. Compaction loop (#3127): MAX_RECOVERY_ATTEMPTS=3 cap + additional suppression
   guard from opencode session in degradation monitor.

Also: refactored extractAutoRetrySignal to auto-retry-signal.ts, new regression
tests for quota classifier and compaction degradation monitor.
This commit is contained in:
YeonGyu-Kim
2026-04-06 17:38:37 +09:00
parent 6c4e0b69a5
commit 61083d499d
15 changed files with 611 additions and 531 deletions
File diff suppressed because it is too large Load Diff
+85 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, beforeEach, afterEach, mock } from "bun:test" import { describe, expect, it, beforeEach, afterEach, mock, afterAll } from "bun:test"
import { createHash, randomBytes } from "node:crypto" import { createHash, randomBytes } from "node:crypto"
import type { OAuthTokenData } from "./storage" import type { OAuthTokenData } from "./storage"
@@ -226,6 +226,90 @@ describe("McpOAuthProvider", () => {
}) })
}) })
describe("refresh", () => {
let originalFetch: typeof globalThis.fetch
let originalEnv: string | undefined
beforeEach(() => {
originalFetch = globalThis.fetch
originalEnv = process.env.OPENCODE_CONFIG_DIR
const { mkdirSync } = require("node:fs")
const { tmpdir } = require("node:os")
const { join } = require("node:path")
const testDir = join(tmpdir(), `mcp-oauth-provider-refresh-test-${Date.now()}`)
mkdirSync(testDir, { recursive: true })
process.env.OPENCODE_CONFIG_DIR = testDir
})
afterEach(() => {
globalThis.fetch = originalFetch
if (originalEnv === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = originalEnv
}
})
it("exchanges refresh token and preserves it when the response omits a new one", async () => {
// Stub fetch to handle both discovery (well-known) and token exchange
const fetchStub = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString()
if (url.includes("oauth-protected-resource")) {
// PRM: return authorization_servers pointing to auth server
return new Response(
JSON.stringify({ authorization_servers: ["https://auth.example.com"] }),
{ status: 200, headers: { "content-type": "application/json" } },
)
}
if (url.includes(".well-known")) {
// AS metadata
return new Response(
JSON.stringify({
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
}),
{ status: 200, headers: { "content-type": "application/json" } },
)
}
// Token exchange
const body = init?.body?.toString() ?? ""
expect(body).toContain("grant_type=refresh_token")
expect(body).toContain("refresh_token=refresh-token-456")
expect(body).toContain("client_id=my-client")
return new Response(
JSON.stringify({ access_token: "refreshed-access-token", expires_in: 3600 }),
{ status: 200, headers: { "content-type": "application/json" } },
)
})
const fetchMock = Object.assign(
async (...args: Parameters<typeof fetch>): ReturnType<typeof fetch> => fetchStub(...args),
{ preconnect: originalFetch.preconnect.bind(originalFetch) },
) satisfies typeof fetch
globalThis.fetch = fetchMock
// given
const providerModule = await importFreshProviderModule()
const provider = new providerModule.McpOAuthProvider({
serverUrl: "https://mcp.example.com",
clientId: "my-client",
})
provider.saveTokens({
accessToken: "old-access-token",
refreshToken: "refresh-token-456",
expiresAt: Math.floor(Date.now() / 1000) - 60,
clientInfo: { clientId: "my-client" },
})
// when
const result = await provider.refresh("refresh-token-456")
// then
expect(result.accessToken).toBe("refreshed-access-token")
expect(result.refreshToken).toBe("refresh-token-456") // preserved from input when absent in response
})
})
describe("redirectUrl", () => { describe("redirectUrl", () => {
it("returns localhost callback URL with default port", () => { it("returns localhost callback URL with default port", () => {
// given // given
+71 -29
View File
@@ -19,6 +19,48 @@ export type McpOAuthProviderOptions = {
scopes?: string[] scopes?: string[]
} }
async function parseTokenResponse(tokenResponse: Response): Promise<Record<string, unknown>> {
if (!tokenResponse.ok) {
let errorDetail = `${tokenResponse.status}`
try {
const body = (await tokenResponse.json()) as Record<string, unknown>
if (body.error) {
errorDetail = `${tokenResponse.status} ${body.error}`
if (body.error_description) {
errorDetail += `: ${body.error_description}`
}
}
} catch {
// Response body not JSON
}
throw new Error(`Token exchange failed: ${errorDetail}`)
}
return (await tokenResponse.json()) as Record<string, unknown>
}
function buildOAuthTokenData(
tokenData: Record<string, unknown>,
clientInfo: ClientCredentials,
fallbackRefreshToken?: string,
): OAuthTokenData {
const accessToken = tokenData.access_token
if (typeof accessToken !== "string") {
throw new Error("Token response missing access_token")
}
return {
accessToken,
refreshToken: typeof tokenData.refresh_token === "string" ? tokenData.refresh_token : fallbackRefreshToken,
expiresAt:
typeof tokenData.expires_in === "number" ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined,
clientInfo: {
clientId: clientInfo.clientId,
...(clientInfo.clientSecret ? { clientSecret: clientInfo.clientSecret } : {}),
},
}
}
export class McpOAuthProvider { export class McpOAuthProvider {
private readonly serverUrl: string private readonly serverUrl: string
private readonly configClientId: string | undefined private readonly configClientId: string | undefined
@@ -131,38 +173,38 @@ export class McpOAuthProvider {
}).toString(), }).toString(),
}) })
if (!tokenResponse.ok) { const tokenData = await parseTokenResponse(tokenResponse)
let errorDetail = `${tokenResponse.status}` const oauthTokenData = buildOAuthTokenData(tokenData, clientInfo)
try {
const body = (await tokenResponse.json()) as Record<string, unknown> this.saveTokens(oauthTokenData)
if (body.error) { return oauthTokenData
errorDetail = `${tokenResponse.status} ${body.error}` }
if (body.error_description) {
errorDetail += `: ${body.error_description}` async refresh(refreshToken: string): Promise<OAuthTokenData> {
} const metadata = await discoverOAuthServerMetadata(this.serverUrl)
} const clientInfo = this.clientInformation()
} catch { const clientId = clientInfo?.clientId ?? this.configClientId
// Response body not JSON if (!clientId) {
} throw new Error("No client information available. Run login() or register a client first.")
throw new Error(`Token exchange failed: ${errorDetail}`)
} }
const tokenData = (await tokenResponse.json()) as Record<string, unknown> const tokenResponse = await fetch(metadata.tokenEndpoint, {
const accessToken = tokenData.access_token method: "POST",
if (typeof accessToken !== "string") { headers: { "content-type": "application/x-www-form-urlencoded" },
throw new Error("Token response missing access_token") body: new URLSearchParams({
} grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
...(clientInfo?.clientSecret ? { client_secret: clientInfo.clientSecret } : {}),
...(metadata.resource ? { resource: metadata.resource } : {}),
}).toString(),
})
const oauthTokenData: OAuthTokenData = { const tokenData = await parseTokenResponse(tokenResponse)
accessToken, const oauthTokenData = buildOAuthTokenData(tokenData, {
refreshToken: typeof tokenData.refresh_token === "string" ? tokenData.refresh_token : undefined, clientId,
expiresAt: ...(clientInfo?.clientSecret ? { clientSecret: clientInfo.clientSecret } : {}),
typeof tokenData.expires_in === "number" ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined, }, refreshToken)
clientInfo: {
clientId: clientInfo.clientId,
clientSecret: clientInfo.clientSecret,
},
}
this.saveTokens(oauthTokenData) this.saveTokens(oauthTokenData)
return oauthTokenData return oauthTokenData
+71 -2
View File
@@ -1,14 +1,16 @@
import { describe, it, expect, beforeEach, afterEach, afterAll, mock, spyOn } from "bun:test" import { describe, it, expect, beforeEach, afterEach, afterAll, mock, spyOn } from "bun:test"
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types" import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
import type { OAuthTokenData } from "../mcp-oauth/storage"
// Mock the MCP SDK transports to avoid network calls // Mock the MCP SDK transports to avoid network calls
const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure"))) const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure")))
const mockHttpClose = mock(() => Promise.resolve()) const mockHttpClose = mock(() => Promise.resolve())
let lastTransportInstance: { url?: URL; options?: { requestInit?: RequestInit } } = {} let lastTransportInstance: { url?: URL; options?: { requestInit?: RequestInit } } = {}
const mockTokens = mock(() => null as { accessToken: string } | null) const mockTokens = mock(() => null as OAuthTokenData | null)
const mockLogin = mock(() => Promise.resolve({ accessToken: "test-token" }) as Promise<{ accessToken: string } | null>) const mockLogin = mock(() => Promise.resolve({ accessToken: "test-token" } satisfies OAuthTokenData))
const mockRefresh = mock((_: string) => Promise.resolve({ accessToken: "refreshed-token" } satisfies OAuthTokenData))
async function importFreshManagerModule(): Promise<typeof import("./manager")> { async function importFreshManagerModule(): Promise<typeof import("./manager")> {
mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
@@ -41,12 +43,14 @@ describe("SkillMcpManager", () => {
createOAuthProvider: () => ({ createOAuthProvider: () => ({
tokens: () => mockTokens(), tokens: () => mockTokens(),
login: () => mockLogin(), login: () => mockLogin(),
refresh: (refreshToken: string) => mockRefresh(refreshToken),
}), }),
}) })
mockHttpConnect.mockClear() mockHttpConnect.mockClear()
mockHttpClose.mockClear() mockHttpClose.mockClear()
mockTokens.mockClear() mockTokens.mockClear()
mockLogin.mockClear() mockLogin.mockClear()
mockRefresh.mockClear()
}) })
afterEach(async () => { afterEach(async () => {
@@ -724,6 +728,71 @@ describe("SkillMcpManager", () => {
expect(headers?.Authorization).toBe("Bearer oauth-token") expect(headers?.Authorization).toBe("Bearer oauth-token")
}) })
it("attempts silent refresh for expired stored tokens before login", async () => {
// given
const info: SkillMcpClientInfo = {
serverName: "oauth-refresh",
skillName: "oauth-skill",
sessionID: "session-oauth-refresh",
}
const config: ClaudeCodeMcpServer = {
url: "https://mcp.example.com/mcp",
oauth: {
clientId: "my-client",
},
}
mockTokens.mockReturnValue({
accessToken: "expired-token",
refreshToken: "refresh-token",
expiresAt: Math.floor(Date.now() / 1000) - 60,
})
mockRefresh.mockResolvedValue({ accessToken: "refreshed-token" })
// when
try {
await manager.getOrCreateClient(info, config)
} catch { /* connection fails in test */ }
// then
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
expect(headers?.Authorization).toBe("Bearer refreshed-token")
expect(mockRefresh).toHaveBeenCalledWith("refresh-token")
expect(mockLogin).not.toHaveBeenCalled()
})
it("falls back to login when silent refresh fails", async () => {
// given
const info: SkillMcpClientInfo = {
serverName: "oauth-refresh-fallback",
skillName: "oauth-skill",
sessionID: "session-oauth-refresh-fallback",
}
const config: ClaudeCodeMcpServer = {
url: "https://mcp.example.com/mcp",
oauth: {
clientId: "my-client",
},
}
mockTokens.mockReturnValue({
accessToken: "expired-token",
refreshToken: "refresh-token",
expiresAt: Math.floor(Date.now() / 1000) - 60,
})
mockRefresh.mockRejectedValue(new Error("Refresh failed"))
mockLogin.mockResolvedValue({ accessToken: "login-token" })
// when
try {
await manager.getOrCreateClient(info, config)
} catch { /* connection fails in test */ }
// then
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
expect(headers?.Authorization).toBe("Bearer login-token")
expect(mockRefresh).toHaveBeenCalledWith("refresh-token")
expect(mockLogin).toHaveBeenCalled()
})
it("does not create auth provider when oauth config is absent", async () => { it("does not create auth provider when oauth config is absent", async () => {
// given // given
const info: SkillMcpClientInfo = { const info: SkillMcpClientInfo = {
@@ -44,7 +44,7 @@ export async function buildHttpRequestInit(
const provider = getOrCreateAuthProvider(authProviders, config.url, config.oauth, createOAuthProvider) const provider = getOrCreateAuthProvider(authProviders, config.url, config.oauth, createOAuthProvider)
let tokenData = provider.tokens() let tokenData = provider.tokens()
if (!tokenData || isTokenExpired(tokenData)) { if (!tokenData) {
try { try {
tokenData = await provider.login() tokenData = await provider.login()
} catch { } catch {
@@ -52,6 +52,20 @@ export async function buildHttpRequestInit(
} }
} }
if (tokenData && isTokenExpired(tokenData)) {
try {
tokenData = tokenData.refreshToken
? await provider.refresh(tokenData.refreshToken)
: await provider.login()
} catch {
try {
tokenData = await provider.login()
} catch {
tokenData = null
}
}
}
if (tokenData) { if (tokenData) {
headers.Authorization = `Bearer ${tokenData.accessToken}` headers.Authorization = `Bearer ${tokenData.accessToken}`
} }
+1 -1
View File
@@ -50,7 +50,7 @@ export interface ProcessCleanupHandler {
export type OAuthProviderLike = Pick< export type OAuthProviderLike = Pick<
McpOAuthProvider, McpOAuthProvider,
"tokens" | "login" "tokens" | "login" | "refresh"
> >
export type OAuthProviderFactory = (options: { export type OAuthProviderFactory = (options: {
@@ -0,0 +1,127 @@
/// <reference types="bun-types" />
import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"
const logMock = mock(() => {})
mock.module("../shared/logger", () => ({
log: logMock,
}))
afterAll(() => { mock.restore() })
const { createPreemptiveCompactionHook } = await import("./preemptive-compaction")
type AssistantHistoryMessage = {
info: {
id: string
role: "assistant"
}
parts: Array<{ type: string; text?: string }>
}
function createMockCtx(sessionHistory: AssistantHistoryMessage[]) {
return {
client: {
session: {
messages: mock(() => Promise.resolve({ data: sessionHistory })),
summarize: mock(() => Promise.resolve({})),
},
tui: {
showToast: mock(() => Promise.resolve({})),
},
},
directory: "/tmp/test",
}
}
function appendAssistantHistory(
sessionHistory: AssistantHistoryMessage[],
input: {
id: string
parts: AssistantHistoryMessage["parts"]
},
): void {
sessionHistory.push({
info: {
id: input.id,
role: "assistant",
},
parts: input.parts,
})
}
function buildAssistantUpdate(input: {
sessionID: string
id: string
parts: unknown[]
}) {
return {
event: {
type: "message.updated",
properties: {
info: {
id: input.id,
role: "assistant",
sessionID: input.sessionID,
providerID: "opencode",
modelID: "kimi-k2.5-free",
finish: true,
tokens: { input: 1000, output: 10, reasoning: 0, cache: { read: 0, write: 0 } },
parts: input.parts,
},
},
},
}
}
describe("preemptive-compaction degradation monitor regressions", () => {
beforeEach(() => {
logMock.mockClear()
})
it("does not re-arm monitoring after recovery-triggered compaction", async () => {
// given
const sessionHistory: AssistantHistoryMessage[] = []
const ctx = createMockCtx(sessionHistory)
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
const sessionID = "ses_recovery_compaction_guard"
const stepOnlyParts = [{ type: "step-start" }, { type: "step-finish" }]
await hook.event({
event: {
type: "session.compacted",
properties: { sessionID },
},
})
// when
appendAssistantHistory(sessionHistory, { id: "msg_1", parts: stepOnlyParts })
await hook.event(buildAssistantUpdate({ sessionID, id: "msg_1", parts: stepOnlyParts }))
appendAssistantHistory(sessionHistory, { id: "msg_2", parts: stepOnlyParts })
await hook.event(buildAssistantUpdate({ sessionID, id: "msg_2", parts: stepOnlyParts }))
appendAssistantHistory(sessionHistory, { id: "msg_3", parts: stepOnlyParts })
await hook.event(buildAssistantUpdate({ sessionID, id: "msg_3", parts: stepOnlyParts }))
await hook.event({
event: {
type: "session.compacted",
properties: { sessionID },
},
})
appendAssistantHistory(sessionHistory, { id: "msg_4", parts: stepOnlyParts })
await hook.event(buildAssistantUpdate({ sessionID, id: "msg_4", parts: stepOnlyParts }))
appendAssistantHistory(sessionHistory, { id: "msg_5", parts: stepOnlyParts })
await hook.event(buildAssistantUpdate({ sessionID, id: "msg_5", parts: stepOnlyParts }))
appendAssistantHistory(sessionHistory, { id: "msg_6", parts: stepOnlyParts })
await hook.event(buildAssistantUpdate({ sessionID, id: "msg_6", parts: stepOnlyParts }))
// then
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1)
})
})
@@ -6,6 +6,7 @@ import { resolveCompactionModel } from "./shared/compaction-model-resolver"
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 120_000 const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 120_000
const POST_COMPACTION_MONITOR_COUNT = 5 const POST_COMPACTION_MONITOR_COUNT = 5
const POST_COMPACTION_NO_TEXT_THRESHOLD = 3 const POST_COMPACTION_NO_TEXT_THRESHOLD = 3
const RECOVERY_COMPACTION_SUPPRESSION_MS = 5_000
declare function setTimeout(handler: () => void, timeout?: number): unknown declare function setTimeout(handler: () => void, timeout?: number): unknown
declare function clearTimeout(timeoutID: unknown): void declare function clearTimeout(timeoutID: unknown): void
@@ -74,6 +75,7 @@ export function createPostCompactionDegradationMonitor(args: {
const postCompactionNoTextStreak = new Map<string, number>() const postCompactionNoTextStreak = new Map<string, number>()
const postCompactionRecoveryTriggered = new Set<string>() const postCompactionRecoveryTriggered = new Set<string>()
const postCompactionEpoch = new Map<string, number>() const postCompactionEpoch = new Map<string, number>()
const suppressRecoveryCompactionUntil = new Map<string, number>()
const postCompactionRecoveryCount = new Map<string, number>() const postCompactionRecoveryCount = new Map<string, number>()
const MAX_RECOVERY_ATTEMPTS = 3 const MAX_RECOVERY_ATTEMPTS = 3
@@ -87,6 +89,13 @@ export function createPostCompactionDegradationMonitor(args: {
} }
const onSessionCompacted = (sessionID: string): void => { const onSessionCompacted = (sessionID: string): void => {
const suppressedUntil = suppressRecoveryCompactionUntil.get(sessionID)
if (suppressedUntil && suppressedUntil > Date.now()) {
suppressRecoveryCompactionUntil.delete(sessionID)
return
}
suppressRecoveryCompactionUntil.delete(sessionID)
const nextEpoch = (postCompactionEpoch.get(sessionID) ?? 0) + 1 const nextEpoch = (postCompactionEpoch.get(sessionID) ?? 0) + 1
postCompactionEpoch.set(sessionID, nextEpoch) postCompactionEpoch.set(sessionID, nextEpoch)
postCompactionRemaining.set(sessionID, POST_COMPACTION_MONITOR_COUNT) postCompactionRemaining.set(sessionID, POST_COMPACTION_MONITOR_COUNT)
@@ -116,6 +125,7 @@ export function createPostCompactionDegradationMonitor(args: {
postCompactionRecoveryTriggered.add(sessionID) postCompactionRecoveryTriggered.add(sessionID)
compactionInProgress.add(sessionID) compactionInProgress.add(sessionID)
const recoveryEpoch = postCompactionEpoch.get(sessionID) ?? 0 const recoveryEpoch = postCompactionEpoch.get(sessionID) ?? 0
suppressRecoveryCompactionUntil.set(sessionID, Date.now() + RECOVERY_COMPACTION_SUPPRESSION_MS)
try { try {
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel( const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
@@ -148,6 +158,7 @@ export function createPostCompactionDegradationMonitor(args: {
log("[preemptive-compaction] Triggered recovery after post-compaction no-text tail", { sessionID }) log("[preemptive-compaction] Triggered recovery after post-compaction no-text tail", { sessionID })
} catch (error) { } catch (error) {
suppressRecoveryCompactionUntil.delete(sessionID)
log("[preemptive-compaction] Failed to recover post-compaction no-text tail", { log("[preemptive-compaction] Failed to recover post-compaction no-text tail", {
sessionID, sessionID,
error: String(error), error: String(error),
@@ -0,0 +1,32 @@
export interface AutoRetrySignal {
signal: string
}
const AUTO_RETRY_PATTERNS: Array<(combined: string) => boolean> = [
(combined) => /retrying\s+in/i.test(combined),
(combined) =>
/(?:too\s+many\s+requests|quota\s+will\s+reset\s+after|quota\s*exceeded|usage\s+limit|rate\s+limit|limit\s+reached|all\s+credentials\s+for\s+model|cool(?:ing)?\s*down|exhausted\s+your\s+capacity)/i.test(combined),
]
export function extractAutoRetrySignal(info: Record<string, unknown> | undefined): AutoRetrySignal | undefined {
if (!info) return undefined
const candidates: string[] = []
const directStatus = info.status
if (typeof directStatus === "string") candidates.push(directStatus)
const summary = info.summary
if (typeof summary === "string") candidates.push(summary)
const message = info.message
if (typeof message === "string") candidates.push(message)
const details = info.details
if (typeof details === "string") candidates.push(details)
const combined = candidates.join("\n")
if (!combined) return undefined
return AUTO_RETRY_PATTERNS.some((test) => test(combined)) ? { signal: combined } : undefined
}
+6 -11
View File
@@ -11,7 +11,7 @@ import type { RuntimeFallbackConfig } from "../../config"
*/ */
export const DEFAULT_CONFIG: Required<RuntimeFallbackConfig> = { export const DEFAULT_CONFIG: Required<RuntimeFallbackConfig> = {
enabled: false, enabled: false,
retry_on_errors: [402, 429, 500, 502, 503, 504], retry_on_errors: [429, 500, 502, 503, 504],
max_fallback_attempts: 3, max_fallback_attempts: 3,
cooldown_seconds: 60, cooldown_seconds: 60,
timeout_seconds: 30, timeout_seconds: 30,
@@ -25,26 +25,21 @@ export const DEFAULT_CONFIG: Required<RuntimeFallbackConfig> = {
export const RETRYABLE_ERROR_PATTERNS = [ export const RETRYABLE_ERROR_PATTERNS = [
/rate.?limit/i, /rate.?limit/i,
/too.?many.?requests/i, /too.?many.?requests/i,
/quota.?exceeded/i,
/quota\s+will\s+reset\s+after/i, /quota\s+will\s+reset\s+after/i,
/quota.?exceeded/i,
/(?:you(?:'ve|\s+have)\s+)?reached\s+your\s+usage\s+limit/i, /(?:you(?:'ve|\s+have)\s+)?reached\s+your\s+usage\s+limit/i,
/all\s+credentials\s+for\s+model/i,
/cool(?:ing)?\s+down/i,
/exhausted\s+your\s+capacity/i, /exhausted\s+your\s+capacity/i,
/usage\s+limit\s+has\s+been\s+reached/i, /usage\s+limit\s+has\s+been\s+reached/i,
/all\s+credentials\s+for\s+model/i,
/cool(?:ing)?\s+down/i,
/model.{0,20}?not.{0,10}?supported/i, /model.{0,20}?not.{0,10}?supported/i,
/model_not_supported/i, /model_not_supported/i,
/insufficient.?(?:credits?|funds?|balance)/i,
/credit.*balance.*too.*low/i,
/service.?unavailable/i, /service.?unavailable/i,
/overloaded/i, /overloaded/i,
/temporarily.?unavailable/i, /temporarily.?unavailable/i,
/try.?again/i, /try.?again/i,
/credit.*balance.*too.*low/i,
/insufficient.?(?:credits?|funds?|balance)/i,
/subscription.*quota/i,
/billing.?(?:hard.?)?limit/i,
/payment.?required/i,
/out\s+of\s+credits?/i,
/(?:^|\s)402(?:\s|$)/,
/(?:^|\s)429(?:\s|$)/, /(?:^|\s)429(?:\s|$)/,
/(?:^|\s)503(?:\s|$)/, /(?:^|\s)503(?:\s|$)/,
/(?:^|\s)529(?:\s|$)/, /(?:^|\s)529(?:\s|$)/,
@@ -181,113 +181,7 @@ describe("extractStatusCode", () => {
}) })
}) })
describe("quota error detection (fixes #2747)", () => { describe("model support fallback", () => {
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("treats hard usage-limit wording as retryable", () => {
//#given
const error = { message: "You've reached your usage limit for this month. Please upgrade to continue." }
//#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)
})
test("detects model_not_supported errors as retryable for fallback chain", () => { test("detects model_not_supported errors as retryable for fallback chain", () => {
//#given //#given
const error1 = { message: "model_not_supported" } const error1 = { message: "model_not_supported" }
+6 -39
View File
@@ -1,5 +1,7 @@
import { DEFAULT_CONFIG, RETRYABLE_ERROR_PATTERNS } from "./constants" import { DEFAULT_CONFIG, RETRYABLE_ERROR_PATTERNS } from "./constants"
export { extractAutoRetrySignal } from "./auto-retry-signal"
export function getErrorMessage(error: unknown): string { export function getErrorMessage(error: unknown): string {
if (!error) return "" if (!error) return ""
if (typeof error === "string") return error.toLowerCase() if (typeof error === "string") return error.toLowerCase()
@@ -137,44 +139,6 @@ export function classifyErrorType(error: unknown): string | undefined {
return undefined return undefined
} }
export interface AutoRetrySignal {
signal: string
}
export const AUTO_RETRY_PATTERNS: Array<(combined: string) => boolean> = [
(combined) => /retrying\s+in/i.test(combined),
(combined) =>
/(?:too\s+many\s+requests|quota\s*exceeded|quota\s+will\s+reset\s+after|usage\s+limit|rate\s+limit|limit\s+reached|all\s+credentials\s+for\s+model|cool(?:ing)?\s*down|exhausted\s+your\s+capacity)/i.test(combined),
]
export function extractAutoRetrySignal(info: Record<string, unknown> | undefined): AutoRetrySignal | undefined {
if (!info) return undefined
const candidates: string[] = []
const directStatus = info.status
if (typeof directStatus === "string") candidates.push(directStatus)
const summary = info.summary
if (typeof summary === "string") candidates.push(summary)
const message = info.message
if (typeof message === "string") candidates.push(message)
const details = info.details
if (typeof details === "string") candidates.push(details)
const combined = candidates.join("\n")
if (!combined) return undefined
const isAutoRetry = AUTO_RETRY_PATTERNS.some((test) => test(combined))
if (isAutoRetry) {
return { signal: combined }
}
return undefined
}
export function containsErrorContent( export function containsErrorContent(
parts: Array<{ type?: string; text?: string }> | undefined parts: Array<{ type?: string; text?: string }> | undefined
): { hasError: boolean; errorMessage?: string } { ): { hasError: boolean; errorMessage?: string } {
@@ -204,7 +168,10 @@ export function isRetryableError(error: unknown, retryOnErrors: number[]): boole
} }
if (errorType === "quota_exceeded") { if (errorType === "quota_exceeded") {
return true // When a provider signals an auto-retry (e.g. "retrying in ~2 weeks"),
// we should still trigger fallback to another model rather than STOP.
const hasAutoRetrySignal = /retrying\s+in/i.test(message)
return hasAutoRetrySignal
} }
if (statusCode && retryOnErrors.includes(statusCode)) { if (statusCode && retryOnErrors.includes(statusCode)) {
@@ -0,0 +1,56 @@
import { describe, expect, test } from "bun:test"
import { classifyErrorType, isRetryableError } from "./error-classifier"
describe("runtime-fallback quota error regressions", () => {
test("classifies subscription quota errors as quota_exceeded and stops retry", () => {
//#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, [429, 500, 502, 503, 504])
//#then
expect(errorType).toBe("quota_exceeded")
expect(retryable).toBe(false)
})
test("treats HTTP 402 payment required as non-retryable", () => {
//#given
const error = { statusCode: 402, message: "Payment Required" }
//#when
const retryable = isRetryableError(error, [429, 500, 502, 503, 504])
//#then
expect(retryable).toBe(false)
})
test("keeps HTTP 429 rate limit retryable", () => {
//#given
const error = { statusCode: 429, message: "Too Many Requests: rate limit reached" }
//#when
const retryable = isRetryableError(error, [429, 500, 502, 503, 504])
//#then
expect(retryable).toBe(true)
})
test("classifies quota error names as quota_exceeded without retry", () => {
//#given
const error = { name: "QuotaExceededError", message: "Request failed." }
//#when
const errorType = classifyErrorType(error)
const retryable = isRetryableError(error, [429, 500, 502, 503, 504])
//#then
expect(errorType).toBe("quota_exceeded")
expect(retryable).toBe(false)
})
})
+22
View File
@@ -215,6 +215,28 @@ describe("model-error-classifier", () => {
//#then //#then
expect(result).toBe(true) expect(result).toBe(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 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)
})
}) })
export {} export {}
+6 -4
View File
@@ -84,23 +84,25 @@ const STOP_MESSAGE_PATTERNS = [
"usage limit has been reached", "usage limit has been reached",
"free usage limit", "free usage limit",
"billing limit", "billing limit",
"billing hard limit",
"monthly limit", "monthly limit",
"plan limit", "plan limit",
"subscription quota",
"subscription limit", "subscription limit",
"payment required",
"out of credits", "out of credits",
"credits exhausted", "credits exhausted",
"insufficient credits", "insufficient credits",
"insufficient balance", "insufficient balance",
"credit balance",
"usage limit for this month",
"exhausted your capacity",
] ]
const AUTO_RETRY_GATE_PATTERNS = [ const AUTO_RETRY_GATE_PATTERNS = [
"rate limit", "rate limit",
"quota",
"usage limit",
"limit reached",
"cooling down", "cooling down",
"credentials for model", "credentials for model",
"exhausted your capacity",
] ]
function hasProviderAutoRetrySignal(message: string): boolean { function hasProviderAutoRetrySignal(message: string): boolean {