fix(FN-714): increase Claude provider timeout to 75s and improve error diagnostics
- Increase Claude fetch timeout from 10s to 75s to accommodate retries and CLI fallback - Include response body snippet in HTTP error messages for better debugging - Improve Claude CLI timeout error message with actionable guidance - Export withTimeout and CLAUDE_FETCH_TIMEOUT_MS for testability - Add comprehensive tests for withTimeout, timeout constant, and error diagnostics
This commit is contained in:
@@ -10,6 +10,8 @@ import {
|
||||
_parseClaudePercentLine,
|
||||
_parseClaudeResetLine,
|
||||
_parseClaudeResetText,
|
||||
withTimeout,
|
||||
CLAUDE_FETCH_TIMEOUT_MS,
|
||||
} from "./usage.js";
|
||||
|
||||
// Mock the https module
|
||||
@@ -2068,4 +2070,103 @@ describe("usage", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("withTimeout", () => {
|
||||
it("resolves with provider result when fetch completes within timeout", async () => {
|
||||
const provider: ProviderUsage = {
|
||||
name: "TestProvider",
|
||||
icon: "🧪",
|
||||
status: "ok",
|
||||
windows: [],
|
||||
};
|
||||
const result = await withTimeout(Promise.resolve(provider), "TestProvider", 5000);
|
||||
expect(result).toEqual(provider);
|
||||
expect(result.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns error provider when fetch exceeds timeout", async () => {
|
||||
const slowPromise = new Promise<ProviderUsage>((resolve) => {
|
||||
setTimeout(() => resolve({ name: "Slow", icon: "🐌", status: "ok", windows: [] }), 10000);
|
||||
});
|
||||
const result = await withTimeout(slowPromise, "Slow", 50); // 50ms timeout
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.error).toBe("Timed out after 0s");
|
||||
expect(result.name).toBe("Slow");
|
||||
});
|
||||
|
||||
it("includes timeout duration in error message for different durations", async () => {
|
||||
// 100ms => "0s"
|
||||
const result100 = await withTimeout(
|
||||
new Promise<ProviderUsage>(() => {}),
|
||||
"Test",
|
||||
100,
|
||||
);
|
||||
expect(result100.error).toBe("Timed out after 0s");
|
||||
|
||||
// 10_000ms is too long to actually wait, but we can verify the format
|
||||
// by using a 1050ms timeout (rounds to 1s)
|
||||
const result1s = await withTimeout(
|
||||
new Promise<ProviderUsage>(() => {}),
|
||||
"Test",
|
||||
1050,
|
||||
);
|
||||
expect(result1s.error).toBe("Timed out after 1s");
|
||||
});
|
||||
|
||||
it("catches rejected promises and returns error provider", async () => {
|
||||
const failingPromise = Promise.reject(new Error("Network failure"));
|
||||
const result = await withTimeout(failingPromise, "Failing", 5000);
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.error).toBe("Network failure");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude timeout constant", () => {
|
||||
it("CLAUDE_FETCH_TIMEOUT_MS is 75 seconds", () => {
|
||||
expect(CLAUDE_FETCH_TIMEOUT_MS).toBe(75_000);
|
||||
});
|
||||
|
||||
it("CLAUDE_FETCH_TIMEOUT_MS is larger than default provider timeout", () => {
|
||||
// The default PROVIDER_FETCH_TIMEOUT_MS is 10_000. CLAUDE_FETCH_TIMEOUT_MS should be much larger.
|
||||
expect(CLAUDE_FETCH_TIMEOUT_MS).toBeGreaterThan(10_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude API error diagnostics", () => {
|
||||
it("includes response body snippet in HTTP 500 error", async () => {
|
||||
mockReadFileSync.mockImplementation((filePath: string) => {
|
||||
if (filePath.includes("claude")) {
|
||||
return JSON.stringify({
|
||||
accessToken: "test-token",
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
}
|
||||
throw new Error("File not found");
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
|
||||
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
mockRequest.mockImplementation((_options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 500,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from('{"error": "internal server error", "details": "something went wrong"}'));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toContain("HTTP 500");
|
||||
expect(claude.error).toContain("internal server error");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -440,7 +440,7 @@ async function fetchClaudeUsageViaCli(): Promise<ProviderUsage> {
|
||||
if (clean.includes("Current session") || clean.includes("% left") || clean.includes("% used")) {
|
||||
resolve(buf);
|
||||
} else {
|
||||
reject(new Error("Claude CLI timed out after 60 seconds. The Claude CLI may be slow to start or authenticate."));
|
||||
reject(new Error("Claude CLI timed out after 60s — got output but no usage data. Try running `claude /usage` manually."));
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
@@ -704,7 +704,8 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
||||
// Any other non-200 status — fail immediately (not transient)
|
||||
if (res.status !== 200) {
|
||||
usage.status = "error";
|
||||
usage.error = `HTTP ${res.status}`;
|
||||
const bodySnippet = res.body ? res.body.slice(0, 100).replace(/\n/g, " ") : "";
|
||||
usage.error = bodySnippet ? `HTTP ${res.status}: ${bodySnippet}` : `HTTP ${res.status}`;
|
||||
return usage;
|
||||
}
|
||||
|
||||
@@ -1231,11 +1232,18 @@ async function fetchZaiUsage(): Promise<ProviderUsage> {
|
||||
/** Max time to wait for any individual provider fetch (ms) */
|
||||
const PROVIDER_FETCH_TIMEOUT_MS = 10_000; // 10 seconds
|
||||
|
||||
/**
|
||||
* Extended timeout for Claude provider fetch (ms).
|
||||
* Claude's flow can include up to 3 API retries with exponential backoff (~7s)
|
||||
* plus a 60-second CLI fallback via PTY, so the default 10s is insufficient.
|
||||
*/
|
||||
export const CLAUDE_FETCH_TIMEOUT_MS = 75_000; // 75 seconds
|
||||
|
||||
/**
|
||||
* Wrap a provider fetch with a timeout. Returns the provider result or an
|
||||
* error provider if the fetch takes longer than PROVIDER_FETCH_TIMEOUT_MS.
|
||||
*/
|
||||
function withTimeout(
|
||||
export function withTimeout(
|
||||
providerPromise: Promise<ProviderUsage>,
|
||||
providerName: string,
|
||||
timeoutMs: number = PROVIDER_FETCH_TIMEOUT_MS,
|
||||
@@ -1246,7 +1254,7 @@ function withTimeout(
|
||||
name: providerName,
|
||||
icon: "⏱️",
|
||||
status: "error",
|
||||
error: "Timed out",
|
||||
error: `Timed out after ${Math.round(timeoutMs / 1000)}s`,
|
||||
windows: [],
|
||||
});
|
||||
}, timeoutMs);
|
||||
@@ -1277,7 +1285,7 @@ export async function fetchAllProviderUsage(_authStorage?: AuthStorageLike): Pro
|
||||
|
||||
// Fetch all providers in parallel with per-provider timeout
|
||||
const results = await Promise.allSettled([
|
||||
withTimeout(fetchClaudeUsage(), "Claude"),
|
||||
withTimeout(fetchClaudeUsage(), "Claude", CLAUDE_FETCH_TIMEOUT_MS),
|
||||
withTimeout(fetchCodexUsage(), "Codex"),
|
||||
withTimeout(fetchGeminiUsage(), "Gemini"),
|
||||
withTimeout(fetchMinimaxUsage(), "Minimax"),
|
||||
|
||||
Reference in New Issue
Block a user