Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions apps/code/src/main/services/mcp-proxy/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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")
);
}

Expand Down
46 changes: 41 additions & 5 deletions apps/code/src/renderer/api/fetcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });

Expand All @@ -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");
Expand Down
17 changes: 15 additions & 2 deletions apps/code/src/renderer/api/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,25 @@ export const buildApiFetcher: (config: {
}
};

const isAuthFailure = async (response: Response): Promise<boolean> => {
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;
}
};
Comment thread
jonathanlab marked this conversation as resolved.

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,
Expand Down
Loading