From 2f50fe393dfb0ce2cf2d27588948e5e93e4b3a88 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Tue, 19 May 2026 08:21:29 -0400 Subject: [PATCH] fix(cliproxy): bound Claude quota error-body reads and preserve timeout Squash merge PR #1300 into dev. --- .../__tests__/quota-fetcher-claude.test.ts | 83 +++++++++++++++++++ src/cliproxy/quota/quota-fetcher-claude.ts | 42 +++++++++- 2 files changed, 121 insertions(+), 4 deletions(-) diff --git a/src/cliproxy/quota/__tests__/quota-fetcher-claude.test.ts b/src/cliproxy/quota/__tests__/quota-fetcher-claude.test.ts index 861ae287..4be8a59a 100644 --- a/src/cliproxy/quota/__tests__/quota-fetcher-claude.test.ts +++ b/src/cliproxy/quota/__tests__/quota-fetcher-claude.test.ts @@ -563,6 +563,31 @@ describe('Claude Quota Fetcher', () => { expect(result.windows).toHaveLength(0); }); + it('falls back to status-only message when error payload is too large', async () => { + createClaudeAccount('claude-large-error@example.com', { + access_token: 'oauth-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + global.fetch = mock(() => + Promise.resolve( + new Response('x'.repeat(9000), { + status: 400, + headers: { + 'Content-Type': 'text/plain', + 'Content-Length': '9000', + }, + }) + ) + ) as typeof fetch; + + const result = await fetchClaudeQuota('claude-large-error@example.com'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Claude OAuth usage API error: 400'); + }); + it('retries once on transient 500 then succeeds', async () => { createClaudeAccount('claude-retry@example.com', { access_token: 'retry-token', @@ -597,6 +622,64 @@ describe('Claude Quota Fetcher', () => { expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(60); }); + it('clears the request timeout before retrying a retryable HTTP error', async () => { + createClaudeAccount('claude-retry-timeout@example.com', { + access_token: 'retry-timeout-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const activeTimers = new Set(); + let nextTimerId = 0; + + globalThis.setTimeout = mock(((handler: TimerHandler, timeout?: number) => { + void handler; + void timeout; + const timerId = ++nextTimerId; + activeTimers.add(timerId); + return timerId as unknown as ReturnType; + }) as typeof setTimeout); + + globalThis.clearTimeout = mock(((timerId?: ReturnType) => { + activeTimers.delete(timerId as unknown as number); + }) as typeof clearTimeout); + + let attempt = 0; + global.fetch = mock(() => { + attempt += 1; + if (attempt === 1) { + return Promise.resolve(new Response('', { status: 500 })); + } + + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { + utilization: 25, + resets_at: '2026-03-01T01:00:00Z', + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ); + }) as typeof fetch; + + try { + const result = await fetchClaudeQuota('claude-retry-timeout@example.com'); + + expect(result.success).toBe(true); + expect(attempt).toBe(2); + expect(globalThis.setTimeout).toHaveBeenCalledTimes(2); + expect(globalThis.clearTimeout).toHaveBeenCalledTimes(2); + expect(activeTimers.size).toBe(0); + } finally { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } + }); + it('retries once after AbortError and succeeds', async () => { createClaudeAccount('claude-timeout@example.com', { access_token: 'timeout-token', diff --git a/src/cliproxy/quota/quota-fetcher-claude.ts b/src/cliproxy/quota/quota-fetcher-claude.ts index 89e08249..4315af74 100644 --- a/src/cliproxy/quota/quota-fetcher-claude.ts +++ b/src/cliproxy/quota/quota-fetcher-claude.ts @@ -21,6 +21,7 @@ export const CLAUDE_OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage const CLAUDE_QUOTA_TIMEOUT_MS = 10000; const CLAUDE_QUOTA_MAX_ATTEMPTS = 2; const CLAUDE_OAUTH_BETA_HEADER = 'oauth-2025-04-20'; +const CLAUDE_QUOTA_ERROR_BODY_MAX_BYTES = 8192; interface ClaudeAuthData { accessToken: string; @@ -79,8 +80,32 @@ function extractErrorMessage(payload: unknown): string | null { async function readResponseErrorMessage(response: Response): Promise { try { - const body = await response.text(); - if (!body || body.trim().length === 0) return null; + const contentLength = Number(response.headers.get('content-length') ?? '0'); + if (Number.isFinite(contentLength) && contentLength > CLAUDE_QUOTA_ERROR_BODY_MAX_BYTES) { + return null; + } + + const reader = response.body?.getReader(); + if (!reader) return null; + + const decoder = new TextDecoder(); + const chunks: string[] = []; + let totalBytes = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + + totalBytes += value.byteLength; + if (totalBytes > CLAUDE_QUOTA_ERROR_BODY_MAX_BYTES) return null; + + chunks.push(decoder.decode(value, { stream: true })); + } + + chunks.push(decoder.decode()); + const body = chunks.join('').trim(); + if (!body) return null; try { const parsed = JSON.parse(body) as unknown; @@ -90,7 +115,7 @@ async function readResponseErrorMessage(response: Response): Promise= 500) ) { + clearTimeout(timeoutId); continue; } + clearTimeout(timeoutId); return buildEmptyResult(lastError, accountId); } @@ -269,16 +298,20 @@ export async function fetchClaudeQuota( try { payload = await response.json(); } catch { + clearTimeout(timeoutId); return buildEmptyResult('Invalid Claude OAuth usage format', accountId); } if (!toObject(payload)) { + clearTimeout(timeoutId); return buildEmptyResult('Invalid Claude OAuth usage format', accountId); } const windows = buildClaudeQuotaWindows(payload as Record); const coreUsage = buildClaudeCoreUsageSummary(windows); + clearTimeout(timeoutId); + return { success: true, windows, @@ -304,6 +337,7 @@ export async function fetchClaudeQuota( } if (attempt >= CLAUDE_QUOTA_MAX_ATTEMPTS) { + clearTimeout(timeoutId); return buildEmptyResult(lastError, accountId); } }