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:
+101
-336
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user