fix(cliproxy): bound Claude quota error-body reads and preserve timeout

Squash merge PR #1300 into dev.
This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-19 08:21:29 -04:00
committed by GitHub
parent 22c13bf913
commit 2f50fe393d
2 changed files with 121 additions and 4 deletions
@@ -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<number>();
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<typeof setTimeout>;
}) as typeof setTimeout);
globalThis.clearTimeout = mock(((timerId?: ReturnType<typeof setTimeout>) => {
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',
+38 -4
View File
@@ -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<string | null> {
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<string | nu
// fall through to plain-text fallback
}
return body.trim();
return body;
} catch {
return null;
}
@@ -230,13 +255,13 @@ export async function fetchClaudeQuota(
},
});
clearTimeout(timeoutId);
if (verbose) {
console.error(`[i] Claude OAuth usage status: ${response.status} (attempt ${attempt})`);
}
if (response.status === 401) {
const errorMessage = await readResponseErrorMessage(response);
clearTimeout(timeoutId);
return buildEmptyResult(
errorMessage || 'Authentication required for Claude OAuth usage',
accountId,
@@ -245,10 +270,12 @@ export async function fetchClaudeQuota(
}
if (response.status === 404) {
clearTimeout(timeoutId);
return buildEmptyResult('Claude OAuth usage endpoint not found', accountId);
}
if (response.status === 403) {
clearTimeout(timeoutId);
return buildEmptyResult('Not authorized for Claude OAuth usage', accountId);
}
@@ -260,8 +287,10 @@ export async function fetchClaudeQuota(
attempt < CLAUDE_QUOTA_MAX_ATTEMPTS &&
(response.status === 429 || response.status >= 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<string, unknown>);
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);
}
}