import { afterEach, describe, expect, mock, test } from 'bun:test' import { APIError } from '@anthropic-ai/sdk' // Helper to build a mock APIError with specific headers function makeError(headers: Record): APIError { const headersObj = new Headers(headers) return { headers: headersObj, status: 429, message: 'rate limit exceeded', name: 'APIError', error: {}, } as unknown as APIError } // Save/restore env vars between tests const originalEnv = { ...process.env } afterEach(() => { for (const key of [ 'CLAUDE_CODE_USE_OPENAI', 'CLAUDE_CODE_USE_GEMINI', 'CLAUDE_CODE_USE_GITHUB', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', ]) { if (originalEnv[key] === undefined) delete process.env[key] else process.env[key] = originalEnv[key] } mock.restore() }) async function importFreshWithRetryModule( provider: | 'firstParty' | 'openai' | 'github' | 'bedrock' | 'vertex' | 'gemini' | 'codex' | 'foundry' = 'firstParty', ) { mock.restore() mock.module('src/utils/model/providers.js', () => ({ getAPIProvider: () => provider, getAPIProviderForStatsig: () => provider, })) return import(`./withRetry.js?ts=${Date.now()}-${Math.random()}`) } // --- parseOpenAIDuration --- describe('parseOpenAIDuration', () => { test('parses seconds: "1s" → 1000', async () => { const { parseOpenAIDuration } = await importFreshWithRetryModule() expect(parseOpenAIDuration('1s')).toBe(1000) }) test('parses minutes+seconds: "6m0s" → 360000', async () => { const { parseOpenAIDuration } = await importFreshWithRetryModule() expect(parseOpenAIDuration('6m0s')).toBe(360000) }) test('parses hours+minutes+seconds: "1h30m0s" → 5400000', async () => { const { parseOpenAIDuration } = await importFreshWithRetryModule() expect(parseOpenAIDuration('1h30m0s')).toBe(5400000) }) test('parses milliseconds: "500ms" → 500', async () => { const { parseOpenAIDuration } = await importFreshWithRetryModule() expect(parseOpenAIDuration('500ms')).toBe(500) }) test('parses minutes only: "2m" → 120000', async () => { const { parseOpenAIDuration } = await importFreshWithRetryModule() expect(parseOpenAIDuration('2m')).toBe(120000) }) test('returns null for empty string', async () => { const { parseOpenAIDuration } = await importFreshWithRetryModule() expect(parseOpenAIDuration('')).toBeNull() }) test('returns null for unrecognized format', async () => { const { parseOpenAIDuration } = await importFreshWithRetryModule() expect(parseOpenAIDuration('invalid')).toBeNull() }) }) // --- getRateLimitResetDelayMs --- describe('getRateLimitResetDelayMs - Anthropic (firstParty)', () => { test('reads anthropic-ratelimit-unified-reset Unix timestamp', async () => { const { getRateLimitResetDelayMs } = await importFreshWithRetryModule('firstParty') const futureUnixSec = Math.floor(Date.now() / 1000) + 60 const error = makeError({ 'anthropic-ratelimit-unified-reset': String(futureUnixSec), }) const delay = getRateLimitResetDelayMs(error) expect(delay).not.toBeNull() expect(delay!).toBeGreaterThan(50_000) expect(delay!).toBeLessThanOrEqual(60_000) }) test('returns null when header absent', async () => { const { getRateLimitResetDelayMs } = await importFreshWithRetryModule('firstParty') const error = makeError({}) expect(getRateLimitResetDelayMs(error)).toBeNull() }) test('returns null when reset is in the past', async () => { const { getRateLimitResetDelayMs } = await importFreshWithRetryModule('firstParty') const pastUnixSec = Math.floor(Date.now() / 1000) - 10 const error = makeError({ 'anthropic-ratelimit-unified-reset': String(pastUnixSec), }) expect(getRateLimitResetDelayMs(error)).toBeNull() }) }) describe('getRateLimitResetDelayMs - OpenAI provider', () => { test('reads x-ratelimit-reset-requests duration string', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' const { getRateLimitResetDelayMs } = await importFreshWithRetryModule('openai') const error = makeError({ 'x-ratelimit-reset-requests': '30s' }) const delay = getRateLimitResetDelayMs(error) expect(delay).toBe(30_000) }) test('reads x-ratelimit-reset-tokens and picks the larger delay', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' const { getRateLimitResetDelayMs } = await importFreshWithRetryModule('openai') const error = makeError({ 'x-ratelimit-reset-requests': '10s', 'x-ratelimit-reset-tokens': '1m0s', }) // Should use the larger of the two so we don't retry before both reset const delay = getRateLimitResetDelayMs(error) expect(delay).toBe(60_000) }) test('returns null when no openai rate limit headers present', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' const { getRateLimitResetDelayMs } = await importFreshWithRetryModule('openai') const error = makeError({}) expect(getRateLimitResetDelayMs(error)).toBeNull() }) test('works for github provider too', async () => { process.env.CLAUDE_CODE_USE_GITHUB = '1' const { getRateLimitResetDelayMs } = await importFreshWithRetryModule('github') const error = makeError({ 'x-ratelimit-reset-requests': '5s' }) expect(getRateLimitResetDelayMs(error)).toBe(5_000) }) }) describe('getRateLimitResetDelayMs - providers without reset headers', () => { test('returns null for bedrock', async () => { process.env.CLAUDE_CODE_USE_BEDROCK = '1' const { getRateLimitResetDelayMs } = await importFreshWithRetryModule('bedrock') const error = makeError({ 'anthropic-ratelimit-unified-reset': String(Math.floor(Date.now() / 1000) + 60) }) // Bedrock doesn't use this header — should still return null expect(getRateLimitResetDelayMs(error)).toBeNull() }) test('returns null for vertex', async () => { process.env.CLAUDE_CODE_USE_VERTEX = '1' const { getRateLimitResetDelayMs } = await importFreshWithRetryModule('vertex') const error = makeError({}) expect(getRateLimitResetDelayMs(error)).toBeNull() }) })