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:
@@ -1,14 +1,16 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll, mock, spyOn } from "bun:test"
|
||||
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./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
|
||||
const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure")))
|
||||
const mockHttpClose = mock(() => Promise.resolve())
|
||||
let lastTransportInstance: { url?: URL; options?: { requestInit?: RequestInit } } = {}
|
||||
|
||||
const mockTokens = mock(() => null as { accessToken: string } | null)
|
||||
const mockLogin = mock(() => Promise.resolve({ accessToken: "test-token" }) as Promise<{ accessToken: string } | null>)
|
||||
const mockTokens = mock(() => null as OAuthTokenData | 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")> {
|
||||
mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
@@ -41,12 +43,14 @@ describe("SkillMcpManager", () => {
|
||||
createOAuthProvider: () => ({
|
||||
tokens: () => mockTokens(),
|
||||
login: () => mockLogin(),
|
||||
refresh: (refreshToken: string) => mockRefresh(refreshToken),
|
||||
}),
|
||||
})
|
||||
mockHttpConnect.mockClear()
|
||||
mockHttpClose.mockClear()
|
||||
mockTokens.mockClear()
|
||||
mockLogin.mockClear()
|
||||
mockRefresh.mockClear()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -724,6 +728,71 @@ describe("SkillMcpManager", () => {
|
||||
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 () => {
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function buildHttpRequestInit(
|
||||
const provider = getOrCreateAuthProvider(authProviders, config.url, config.oauth, createOAuthProvider)
|
||||
let tokenData = provider.tokens()
|
||||
|
||||
if (!tokenData || isTokenExpired(tokenData)) {
|
||||
if (!tokenData) {
|
||||
try {
|
||||
tokenData = await provider.login()
|
||||
} 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) {
|
||||
headers.Authorization = `Bearer ${tokenData.accessToken}`
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export interface ProcessCleanupHandler {
|
||||
|
||||
export type OAuthProviderLike = Pick<
|
||||
McpOAuthProvider,
|
||||
"tokens" | "login"
|
||||
"tokens" | "login" | "refresh"
|
||||
>
|
||||
|
||||
export type OAuthProviderFactory = (options: {
|
||||
|
||||
Reference in New Issue
Block a user