diff --git a/apps/code/src/main/services/mcp-proxy/service.ts b/apps/code/src/main/services/mcp-proxy/service.ts index 07ffd9241..1cf267355 100644 --- a/apps/code/src/main/services/mcp-proxy/service.ts +++ b/apps/code/src/main/services/mcp-proxy/service.ts @@ -183,10 +183,11 @@ export class McpProxyService { const buf = Buffer.from(await response.arrayBuffer()); const bodyText = buf.toString("utf8"); - if (this.isAuthErrorBody(bodyText)) { - log.warn("MCP auth error in body — refreshing token and retrying", { + if (this.isAuthErrorBody(bodyText, response.status)) { + log.warn("MCP auth failure — refreshing token and retrying", { id, url, + status: response.status, }); await this.authService.refreshAccessToken(); response = await this.authService.authenticatedFetch( @@ -235,10 +236,17 @@ export class McpProxyService { } } - private isAuthErrorBody(bodyText: string): boolean { - return ( + private isAuthErrorBody(bodyText: string, status: number): boolean { + if ( bodyText.includes('"authentication_failed"') || bodyText.includes('"authentication_error"') + ) { + return true; + } + if (status < 400) return false; + return ( + bodyText.includes("Invalid API key") || + bodyText.includes("Authentication failed") ); } diff --git a/apps/code/src/renderer/api/fetcher.test.ts b/apps/code/src/renderer/api/fetcher.test.ts index 62a510620..b74b32409 100644 --- a/apps/code/src/renderer/api/fetcher.test.ts +++ b/apps/code/src/renderer/api/fetcher.test.ts @@ -13,15 +13,15 @@ describe("buildApiFetcher", () => { status: 200, json: () => Promise.resolve(data), }); - const err = (status: number) => { + const err = (status: number, body: object = { error: status }) => { const response = { ok: false, status, statusText: `Error ${status}`, - json: () => Promise.resolve({ error: status }), + json: () => Promise.resolve(body), clone: () => ({ ...response, - text: () => Promise.resolve(`Error ${status}`), + text: () => Promise.resolve(JSON.stringify(body)), }), }; return response; @@ -63,10 +63,10 @@ describe("buildApiFetcher", () => { ); }); - it("does not retry on non-401 errors", async () => { + it("does not retry on 403 without authentication_failed body", async () => { const getAccessToken = vi.fn().mockResolvedValue("token"); const refreshAccessToken = vi.fn().mockResolvedValue("new-token"); - mockFetch.mockResolvedValueOnce(err(403)); + mockFetch.mockResolvedValueOnce(err(403, { detail: "Permission denied." })); const fetcher = buildApiFetcher({ getAccessToken, refreshAccessToken }); @@ -75,6 +75,42 @@ describe("buildApiFetcher", () => { expect(refreshAccessToken).not.toHaveBeenCalled(); }); + it("retries with a fresh token on 403 with authentication_failed body", async () => { + const getAccessToken = vi.fn().mockResolvedValue("stale-token"); + const refreshAccessToken = vi.fn().mockResolvedValue("fresh-token"); + mockFetch + .mockResolvedValueOnce( + err(403, { + type: "authentication_error", + code: "authentication_failed", + detail: "Invalid access token.", + }), + ) + .mockResolvedValueOnce(ok()); + + const fetcher = buildApiFetcher({ getAccessToken, refreshAccessToken }); + const response = await fetcher.fetch(mockInput); + + expect(response.ok).toBe(true); + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[1][1].headers.get("Authorization")).toBe( + "Bearer fresh-token", + ); + }); + + it("does not retry on other 4xx errors", async () => { + const refreshAccessToken = vi.fn().mockResolvedValue("new-token"); + mockFetch.mockResolvedValueOnce(err(400, { detail: "Bad request." })); + + const fetcher = buildApiFetcher({ + getAccessToken: vi.fn().mockResolvedValue("token"), + refreshAccessToken, + }); + + await expect(fetcher.fetch(mockInput)).rejects.toThrow("[400]"); + expect(refreshAccessToken).not.toHaveBeenCalled(); + }); + it("throws when the retry still returns 401", async () => { const getAccessToken = vi.fn().mockResolvedValue("token-1"); const refreshAccessToken = vi.fn().mockResolvedValue("token-2"); diff --git a/apps/code/src/renderer/api/fetcher.ts b/apps/code/src/renderer/api/fetcher.ts index 74a45e26d..2978f0b1c 100644 --- a/apps/code/src/renderer/api/fetcher.ts +++ b/apps/code/src/renderer/api/fetcher.ts @@ -52,12 +52,25 @@ export const buildApiFetcher: (config: { } }; + const isAuthFailure = async (response: Response): Promise => { + if (response.status === 401) return true; + if (response.status !== 403) return false; + try { + const body = await response.clone().json(); + return ( + body?.code === "authentication_failed" || + body?.type === "authentication_error" + ); + } catch { + return false; + } + }; + return { fetch: async (input) => { let response = await makeRequest(input, await config.getAccessToken()); - // Retry once on 401 after asking main for a fresh valid token again. - if (!response.ok && response.status === 401) { + if (!response.ok && (await isAuthFailure(response))) { try { response = await makeRequest( input,