diff --git a/src/cliproxy/quota/__tests__/quota-fetcher-claude.test.ts b/src/cliproxy/quota/__tests__/quota-fetcher-claude.test.ts index 36b84903..480a7d11 100644 --- a/src/cliproxy/quota/__tests__/quota-fetcher-claude.test.ts +++ b/src/cliproxy/quota/__tests__/quota-fetcher-claude.test.ts @@ -622,7 +622,7 @@ describe('Claude Quota Fetcher', () => { expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(60); }); - it('does NOT inner-retry on 429; returns retryable single-attempt result honoring Retry-After', async () => { + it('classifies 429 as usage-probe unavailability without implying inference is limited', async () => { // Safety intent: 429 must NOT trigger an immediate, delay-free inner retry. // The outer 10-min cache + circuit breaker honor Retry-After and bound total // volume, so a single attempt is made and the retryable signal is surfaced. @@ -636,18 +636,66 @@ describe('Claude Quota Fetcher', () => { global.fetch = mock(() => { attempt += 1; return Promise.resolve( - new Response('', { status: 429, headers: { 'Retry-After': '120' } }) + new Response(JSON.stringify({ message: 'Rate limited. Please try again later.' }), { + status: 429, + headers: { 'Retry-After': '0', 'Content-Type': 'application/json' }, + }) ); }) as typeof fetch; const result = await fetchClaudeQuota('claude-429@example.com'); expect(result.success).toBe(false); - // Single attempt — no inner retry burned on the 429. expect(attempt).toBe(1); + expect(result.error).toBe('Claude usage status temporarily unavailable'); + expect(result.error).not.toContain('Rate limited'); + expect(result.errorCode).toBe('usage_probe_unavailable'); + expect(result.actionHint).toContain('Inference may still be available'); expect(result.httpStatus).toBe(429); expect(result.retryable).toBe(true); - expect(result.errorDetail).toBe('retry-after:120'); + expect(result.errorDetail).toBe('retry-after:0'); + expect(result.needsReauth).toBe(false); + }); + + it('keeps 403 OAuth usage responses on the authorization-failure path', async () => { + createClaudeAccount('claude-usage-403@example.com', { + access_token: 'oauth-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + global.fetch = mock(() => Promise.resolve(new Response('', { status: 403 }))) as typeof fetch; + + const result = await fetchClaudeQuota('claude-usage-403@example.com'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Not authorized for Claude OAuth usage'); + expect(result.errorCode).toBeUndefined(); + expect(result.httpStatus).toBeUndefined(); + expect(result.retryable).toBeUndefined(); + }); + + it('keeps terminal 5xx responses on the generic retryable server-error path', async () => { + createClaudeAccount('claude-usage-503@example.com', { + access_token: 'oauth-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + let attempt = 0; + global.fetch = mock(() => { + attempt += 1; + return Promise.resolve(new Response('Service unavailable', { status: 503 })); + }) as typeof fetch; + + const result = await fetchClaudeQuota('claude-usage-503@example.com'); + + expect(attempt).toBe(2); + expect(result.success).toBe(false); + expect(result.error).toBe('Service unavailable'); + expect(result.errorCode).toBeUndefined(); + expect(result.httpStatus).toBe(503); + expect(result.retryable).toBe(true); }); it('clears the request timeout before retrying a retryable HTTP error', async () => { diff --git a/tests/unit/commands/cliproxy-quota-subcommand.test.ts b/tests/unit/commands/cliproxy-quota-subcommand.test.ts index 955ea422..dc1fa4b9 100644 --- a/tests/unit/commands/cliproxy-quota-subcommand.test.ts +++ b/tests/unit/commands/cliproxy-quota-subcommand.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'bun:test'; +import { displayClaudeQuotaSection } from '../../../src/commands/cliproxy/quota-subcommand/sections/claude'; async function loadQuotaCommandTestExports() { const moduleId = Date.now() + Math.random(); @@ -9,6 +10,43 @@ async function loadQuotaCommandTestExports() { } describe('cliproxy quota subcommand failure formatting', () => { + it('renders Claude usage-probe 429 as a warning with an inference-safe hint', () => { + const output: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => output.push(args.map(String).join(' ')); + + try { + displayClaudeQuotaSection([ + { + account: 'healthy@example.com', + quota: { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: 1, + accountId: 'healthy@example.com', + error: 'Claude usage status temporarily unavailable', + errorCode: 'usage_probe_unavailable', + actionHint: + 'Inference may still be available. Retry the Claude quota status check later.', + httpStatus: 429, + errorDetail: 'retry-after:0', + retryable: true, + }, + }, + ]); + } finally { + console.log = originalLog; + } + + expect(output.join('\n')).toContain('[!] healthy@example.com'); + expect(output.join('\n')).not.toContain('[X] healthy@example.com'); + expect(output.join('\n')).toContain('Claude usage status temporarily unavailable'); + expect(output.join('\n')).toContain('Inference may still be available'); + expect(output.join('\n')).toContain('HTTP 429 | Code: usage_probe_unavailable | Retryable'); + expect(output.join('\n')).toContain('Detail: retry-after:0'); + }); + it('builds Gemini failure lines with the remediation hint, code, and detail', async () => { const { getQuotaFailureDisplayEntries } = await loadQuotaCommandTestExports();