diff --git a/package.json b/package.json index bfb7f41..2e71a17 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccstatusline", - "version": "2.2.2", + "version": "2.2.3", "description": "A customizable status line formatter for Claude Code CLI", "module": "src/ccstatusline.ts", "type": "module", diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index 33aa854..9ba86ae 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -16,7 +16,7 @@ export interface RenderUsageData { extraUsageLimit?: number; extraUsageUsed?: number; extraUsageUtilization?: number; - error?: 'no-credentials' | 'timeout' | 'api-error' | 'parse-error'; + error?: 'no-credentials' | 'timeout' | 'rate-limited' | 'api-error' | 'parse-error'; } export interface RenderContext { diff --git a/src/utils/__tests__/usage-fetch.test.ts b/src/utils/__tests__/usage-fetch.test.ts index 548ad5d..b93154b 100644 --- a/src/utils/__tests__/usage-fetch.test.ts +++ b/src/utils/__tests__/usage-fetch.test.ts @@ -17,54 +17,34 @@ interface UsageProbeResult { requestCount: number; proxyAgentConfigured: boolean; requestHost: string | null; + lockContents: string | null; } -describe('fetchUsageData error handling', () => { - it('preserves root errors within lock window and avoids locking on no-credentials', () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-usage-test-')); - const probeScriptPath = path.join(tempRoot, 'probe-usage.mjs'); - const usageModulePath = fileURLToPath(new URL('../usage.ts', import.meta.url)); +interface TokenHome { + bin: string; + claudeConfig: string; + home: string; +} - const noCredentialsHome = path.join(tempRoot, 'home-no-credentials'); - const apiErrorHome = path.join(tempRoot, 'home-api-error'); - const apiErrorBin = path.join(tempRoot, 'bin-api-error'); - const apiErrorClaudeConfig = path.join(tempRoot, 'claude-api-error'); - const invalidProxyHome = path.join(tempRoot, 'home-invalid-proxy'); - const successHome = path.join(tempRoot, 'home-success'); - const successBin = path.join(tempRoot, 'bin-success'); - const successClaudeConfig = path.join(tempRoot, 'claude-success'); - const securityScript = path.join(apiErrorBin, 'security'); - const successSecurityScript = path.join(successBin, 'security'); - const credentialsFile = path.join(apiErrorClaudeConfig, '.credentials.json'); - const successCredentialsFile = path.join(successClaudeConfig, '.credentials.json'); - const successResponseBody = JSON.stringify({ - five_hour: { - utilization: 42, - resets_at: '2030-01-01T00:00:00.000Z' - }, - seven_day: { - utilization: 17, - resets_at: '2030-01-07T00:00:00.000Z' - } - }); +interface ProbeOptions { + claudeConfigDir?: string; + home: string; + httpsProxy?: string; + lowercaseHttpsProxy?: string; + mode?: 'error' | 'status' | 'success' | 'unexpected'; + nowMs: number; + pathDir?: string; + responseBody?: string; + responseHeaders?: Record; + statusCode?: number; +} - fs.mkdirSync(noCredentialsHome, { recursive: true }); - fs.mkdirSync(apiErrorHome, { recursive: true }); - fs.mkdirSync(apiErrorBin, { recursive: true }); - fs.mkdirSync(apiErrorClaudeConfig, { recursive: true }); - fs.mkdirSync(invalidProxyHome, { recursive: true }); - fs.mkdirSync(successHome, { recursive: true }); - fs.mkdirSync(successBin, { recursive: true }); - fs.mkdirSync(successClaudeConfig, { recursive: true }); +function createProbeHarness() { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-usage-test-')); + const probeScriptPath = path.join(tempRoot, 'probe-usage.mjs'); + const usageModulePath = fileURLToPath(new URL('../usage.ts', import.meta.url)); - fs.writeFileSync(securityScript, '#!/bin/sh\necho \'{"claudeAiOauth":{"accessToken":"test-token"}}\'\n'); - fs.chmodSync(securityScript, 0o755); - fs.writeFileSync(credentialsFile, JSON.stringify({ claudeAiOauth: { accessToken: 'test-token' } })); - fs.writeFileSync(successSecurityScript, '#!/bin/sh\necho \'{"claudeAiOauth":{"accessToken":"test-token"}}\'\n'); - fs.chmodSync(successSecurityScript, 0o755); - fs.writeFileSync(successCredentialsFile, JSON.stringify({ claudeAiOauth: { accessToken: 'test-token' } })); - - const probeScript = ` + const probeScript = ` import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -74,6 +54,8 @@ const require = createRequire(import.meta.url); const https = require('https'); const mode = process.env.TEST_REQUEST_MODE || 'success'; const responseBody = process.env.TEST_RESPONSE_BODY || ''; +const responseHeaders = JSON.parse(process.env.TEST_RESPONSE_HEADERS_JSON || '{}'); +const statusCode = Number(process.env.TEST_STATUS_CODE || (mode === 'success' ? '200' : '500')); let requestCount = 0; let proxyAgentConfigured = false; let requestHost = null; @@ -88,7 +70,8 @@ https.request = (...args) => { const responseHandlers = new Map(); const response = { - statusCode: mode === 'success' ? 200 : 500, + headers: responseHeaders, + statusCode, setEncoding() {}, on(event, handler) { const existing = responseHandlers.get(event) || []; @@ -115,24 +98,28 @@ https.request = (...args) => { return; } - if (mode === 'success') { - if (callback) { - callback(response); - } - const dataHandlers = responseHandlers.get('data') || []; - for (const handler of dataHandlers) { - handler(Buffer.from(responseBody)); - } - const endHandlers = responseHandlers.get('end') || []; - for (const handler of endHandlers) { - handler(); + if (mode === 'unexpected') { + const handlers = requestHandlers.get('error') || []; + for (const handler of handlers) { + handler(new Error('unexpected request')); } return; } - const handlers = requestHandlers.get('error') || []; - for (const handler of handlers) { - handler(new Error('unexpected request')); + if (callback) { + callback(response); + } + + if (responseBody !== '') { + const dataHandlers = responseHandlers.get('data') || []; + for (const handler of dataHandlers) { + handler(responseBody); + } + } + + const endHandlers = responseHandlers.get('end') || []; + for (const handler of endHandlers) { + handler(); } } }; @@ -156,25 +143,125 @@ process.stdout.write(JSON.stringify({ cacheExists: fs.existsSync(cacheFile), requestCount, proxyAgentConfigured, - requestHost + requestHost, + lockContents: fs.existsSync(lockFile) ? fs.readFileSync(lockFile, 'utf8') : null })); `; - try { - fs.writeFileSync(probeScriptPath, probeScript); + fs.writeFileSync(probeScriptPath, probeScript); - const noCredentialsOutput = execFileSync(process.execPath, [probeScriptPath], { - encoding: 'utf8', - env: { - ...process.env, - HOME: noCredentialsHome, - PATH: '/nonexistent', - TEST_REQUEST_MODE: 'unexpected', - TEST_NOW_MS: '2200000000000' - } + function createEmptyHome(name: string): { home: string } { + const home = path.join(tempRoot, `home-${name}`); + fs.mkdirSync(home, { recursive: true }); + return { home }; + } + + function createTokenHome(name: string): TokenHome { + const home = path.join(tempRoot, `home-${name}`); + const bin = path.join(tempRoot, `bin-${name}`); + const claudeConfig = path.join(tempRoot, `claude-${name}`); + const securityScript = path.join(bin, 'security'); + const credentialsFile = path.join(claudeConfig, '.credentials.json'); + + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(claudeConfig, { recursive: true }); + + fs.writeFileSync(securityScript, '#!/bin/sh\necho \'{"claudeAiOauth":{"accessToken":"test-token"}}\'\n'); + fs.chmodSync(securityScript, 0o755); + fs.writeFileSync(credentialsFile, JSON.stringify({ claudeAiOauth: { accessToken: 'test-token' } })); + + return { + bin, + claudeConfig, + home + }; + } + + function runProbe(options: ProbeOptions): UsageProbeResult { + const output = execFileSync(process.execPath, [probeScriptPath], { + encoding: 'utf8', + env: { + ...process.env, + HOME: options.home, + PATH: options.pathDir ?? '/nonexistent', + TEST_NOW_MS: String(options.nowMs), + TEST_REQUEST_MODE: options.mode ?? 'success', + TEST_RESPONSE_BODY: options.responseBody ?? '', + TEST_RESPONSE_HEADERS_JSON: JSON.stringify(options.responseHeaders ?? {}), + TEST_STATUS_CODE: String(options.statusCode ?? (options.mode === 'success' ? 200 : 500)), + ...(options.claudeConfigDir ? { CLAUDE_CONFIG_DIR: options.claudeConfigDir } : {}), + ...(options.httpsProxy !== undefined ? { HTTPS_PROXY: options.httpsProxy } : {}), + ...(options.lowercaseHttpsProxy !== undefined ? { https_proxy: options.lowercaseHttpsProxy } : {}) + } + }); + + return JSON.parse(output) as UsageProbeResult; + } + + function cleanup(): void { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + + return { + cleanup, + createEmptyHome, + createTokenHome, + runProbe + }; +} + +function parseLockContents(lockContents: string | null): { blockedUntil: number; error?: string } | null { + return lockContents ? JSON.parse(lockContents) as { blockedUntil: number; error?: string } : null; +} + +describe('fetchUsageData error handling', () => { + const nowMs = 2200000000000; + const successResponseBody = JSON.stringify({ + five_hour: { + utilization: 42, + resets_at: '2030-01-01T00:00:00.000Z' + }, + seven_day: { + utilization: 17, + resets_at: '2030-01-07T00:00:00.000Z' + } + }); + const updatedSuccessResponseBody = JSON.stringify({ + five_hour: { + utilization: 55, + resets_at: '2030-01-02T00:00:00.000Z' + }, + seven_day: { + utilization: 21, + resets_at: '2030-01-08T00:00:00.000Z' + } + }); + const rateLimitedResponseBody = JSON.stringify({ + error: { + message: 'Rate limited. Please try again later.', + type: 'rate_limit_error' + } + }); + + it('preserves root errors within a process and keeps existing proxy and cache behavior', () => { + const harness = createProbeHarness(); + + try { + const noCredentialsHome = harness.createEmptyHome('no-credentials'); + const apiErrorHome = harness.createTokenHome('api-error'); + const successHome = harness.createTokenHome('success'); + const invalidProxyHome = harness.createTokenHome('invalid-proxy'); + const proxyHome = harness.createTokenHome('proxy'); + const blankProxyHome = harness.createTokenHome('blank-proxy'); + const lowercaseProxyHome = harness.createTokenHome('lowercase-proxy'); + + const noCredentialsResult = harness.runProbe({ + home: noCredentialsHome.home, + mode: 'unexpected', + nowMs }); - const noCredentialsResult = JSON.parse(noCredentialsOutput) as UsageProbeResult; expect(noCredentialsResult.first).toEqual({ error: 'no-credentials' }); expect(noCredentialsResult.second).toEqual({ error: 'no-credentials' }); expect(noCredentialsResult.lockExists).toBe(false); @@ -182,40 +269,46 @@ process.stdout.write(JSON.stringify({ expect(noCredentialsResult.requestCount).toBe(0); expect(noCredentialsResult.proxyAgentConfigured).toBe(false); - const apiErrorOutput = execFileSync(process.execPath, [probeScriptPath], { - encoding: 'utf8', - env: { - ...process.env, - HOME: apiErrorHome, - PATH: apiErrorBin, - CLAUDE_CONFIG_DIR: apiErrorClaudeConfig, - TEST_REQUEST_MODE: 'error', - TEST_NOW_MS: '2200000000000' - } + const apiErrorResult = harness.runProbe({ + claudeConfigDir: apiErrorHome.claudeConfig, + home: apiErrorHome.home, + mode: 'error', + nowMs, + pathDir: apiErrorHome.bin }); - const apiErrorResult = JSON.parse(apiErrorOutput) as UsageProbeResult; expect(apiErrorResult.first).toEqual({ error: 'api-error' }); expect(apiErrorResult.second).toEqual({ error: 'api-error' }); expect(apiErrorResult.cacheExists).toBe(false); expect(apiErrorResult.requestCount).toBe(1); expect(apiErrorResult.proxyAgentConfigured).toBe(false); expect(apiErrorResult.requestHost).toBe('api.anthropic.com'); - - const successOutput = execFileSync(process.execPath, [probeScriptPath], { - encoding: 'utf8', - env: { - ...process.env, - HOME: successHome, - PATH: successBin, - CLAUDE_CONFIG_DIR: successClaudeConfig, - TEST_REQUEST_MODE: 'success', - TEST_RESPONSE_BODY: successResponseBody, - TEST_NOW_MS: '2200000000000' - } + expect(parseLockContents(apiErrorResult.lockContents)).toEqual({ + blockedUntil: Math.floor(nowMs / 1000) + 30, + error: 'timeout' + }); + + const genericLockResult = harness.runProbe({ + claudeConfigDir: apiErrorHome.claudeConfig, + home: apiErrorHome.home, + mode: 'unexpected', + nowMs, + pathDir: apiErrorHome.bin + }); + + expect(genericLockResult.first).toEqual({ error: 'timeout' }); + expect(genericLockResult.second).toEqual({ error: 'timeout' }); + expect(genericLockResult.requestCount).toBe(0); + + const successResult = harness.runProbe({ + claudeConfigDir: successHome.claudeConfig, + home: successHome.home, + mode: 'success', + nowMs, + pathDir: successHome.bin, + responseBody: successResponseBody }); - const successResult = JSON.parse(successOutput) as UsageProbeResult; expect(successResult.first).toEqual({ sessionUsage: 42, sessionResetAt: '2030-01-01T00:00:00.000Z', @@ -228,126 +321,278 @@ process.stdout.write(JSON.stringify({ expect(successResult.proxyAgentConfigured).toBe(false); expect(successResult.requestHost).toBe('api.anthropic.com'); - const httpsProxyOutput = execFileSync(process.execPath, [probeScriptPath], { - encoding: 'utf8', - env: { - ...process.env, - HOME: apiErrorHome, - PATH: apiErrorBin, - CLAUDE_CONFIG_DIR: apiErrorClaudeConfig, - HTTPS_PROXY: 'http://proxy.local:8080', - TEST_REQUEST_MODE: 'success', - TEST_RESPONSE_BODY: successResponseBody, - TEST_NOW_MS: '2200000000000' - } + const httpsProxyResult = harness.runProbe({ + claudeConfigDir: proxyHome.claudeConfig, + home: proxyHome.home, + httpsProxy: 'http://proxy.local:8080', + mode: 'success', + nowMs, + pathDir: proxyHome.bin, + responseBody: successResponseBody }); - const httpsProxyResult = JSON.parse(httpsProxyOutput) as UsageProbeResult; expect(httpsProxyResult.first).toEqual(successResult.first); expect(httpsProxyResult.second).toEqual(successResult.first); expect(httpsProxyResult.requestCount).toBe(1); expect(httpsProxyResult.proxyAgentConfigured).toBe(true); expect(httpsProxyResult.requestHost).toBe('api.anthropic.com'); - const lowercaseProxyOutput = execFileSync(process.execPath, [probeScriptPath], { - encoding: 'utf8', - env: { - ...process.env, - HOME: apiErrorHome, - PATH: apiErrorBin, - CLAUDE_CONFIG_DIR: apiErrorClaudeConfig, - https_proxy: 'http://proxy.local:8080', - TEST_REQUEST_MODE: 'success', - TEST_RESPONSE_BODY: successResponseBody, - TEST_NOW_MS: '2200000000000' - } + const lowercaseProxyResult = harness.runProbe({ + claudeConfigDir: lowercaseProxyHome.claudeConfig, + home: lowercaseProxyHome.home, + lowercaseHttpsProxy: 'http://proxy.local:8080', + mode: 'success', + nowMs, + pathDir: lowercaseProxyHome.bin, + responseBody: successResponseBody }); - const lowercaseProxyResult = JSON.parse(lowercaseProxyOutput) as UsageProbeResult; expect(lowercaseProxyResult.first).toEqual(successResult.first); expect(lowercaseProxyResult.second).toEqual(successResult.first); expect(lowercaseProxyResult.requestCount).toBe(1); expect(lowercaseProxyResult.proxyAgentConfigured).toBe(false); - const blankProxyOutput = execFileSync(process.execPath, [probeScriptPath], { - encoding: 'utf8', - env: { - ...process.env, - HOME: apiErrorHome, - PATH: apiErrorBin, - CLAUDE_CONFIG_DIR: apiErrorClaudeConfig, - HTTPS_PROXY: ' ', - TEST_REQUEST_MODE: 'success', - TEST_RESPONSE_BODY: successResponseBody, - TEST_NOW_MS: '2200000000000' - } + const blankProxyResult = harness.runProbe({ + claudeConfigDir: blankProxyHome.claudeConfig, + home: blankProxyHome.home, + httpsProxy: ' ', + mode: 'success', + nowMs, + pathDir: blankProxyHome.bin, + responseBody: successResponseBody }); - const blankProxyResult = JSON.parse(blankProxyOutput) as UsageProbeResult; expect(blankProxyResult.first).toEqual(successResult.first); expect(blankProxyResult.second).toEqual(successResult.first); expect(blankProxyResult.requestCount).toBe(1); expect(blankProxyResult.proxyAgentConfigured).toBe(false); - const invalidProxyOutput = execFileSync(process.execPath, [probeScriptPath], { - encoding: 'utf8', - env: { - ...process.env, - HOME: invalidProxyHome, - PATH: apiErrorBin, - CLAUDE_CONFIG_DIR: apiErrorClaudeConfig, - HTTPS_PROXY: '://bad-proxy', - TEST_REQUEST_MODE: 'success', - TEST_RESPONSE_BODY: successResponseBody, - TEST_NOW_MS: '2200000000000' - } + const invalidProxyResult = harness.runProbe({ + claudeConfigDir: invalidProxyHome.claudeConfig, + home: invalidProxyHome.home, + httpsProxy: '://bad-proxy', + mode: 'success', + nowMs, + pathDir: invalidProxyHome.bin, + responseBody: successResponseBody }); - const invalidProxyResult = JSON.parse(invalidProxyOutput) as UsageProbeResult; expect(invalidProxyResult.first).toEqual({ error: 'api-error' }); expect(invalidProxyResult.second).toEqual({ error: 'api-error' }); expect(invalidProxyResult.requestCount).toBe(0); expect(invalidProxyResult.proxyAgentConfigured).toBe(false); - const staleProxyOutput = execFileSync(process.execPath, [probeScriptPath], { - encoding: 'utf8', - env: { - ...process.env, - HOME: successHome, - PATH: successBin, - CLAUDE_CONFIG_DIR: successClaudeConfig, - HTTPS_PROXY: '://bad-proxy', - TEST_REQUEST_MODE: 'success', - TEST_RESPONSE_BODY: successResponseBody, - TEST_NOW_MS: '2200000181000' - } + const staleProxyResult = harness.runProbe({ + claudeConfigDir: successHome.claudeConfig, + home: successHome.home, + httpsProxy: '://bad-proxy', + mode: 'success', + nowMs: nowMs + 181000, + pathDir: successHome.bin, + responseBody: successResponseBody }); - const staleProxyResult = JSON.parse(staleProxyOutput) as UsageProbeResult; expect(staleProxyResult.first).toEqual(successResult.first); expect(staleProxyResult.second).toEqual(successResult.first); expect(staleProxyResult.requestCount).toBe(0); expect(staleProxyResult.proxyAgentConfigured).toBe(false); - const cachedSuccessOutput = execFileSync(process.execPath, [probeScriptPath], { - encoding: 'utf8', - env: { - ...process.env, - HOME: successHome, - PATH: successBin, - CLAUDE_CONFIG_DIR: successClaudeConfig, - TEST_REQUEST_MODE: 'unexpected', - TEST_NOW_MS: '2200000000000' - } + const cachedSuccessResult = harness.runProbe({ + claudeConfigDir: successHome.claudeConfig, + home: successHome.home, + mode: 'unexpected', + nowMs, + pathDir: successHome.bin }); - const cachedSuccessResult = JSON.parse(cachedSuccessOutput) as UsageProbeResult; expect(cachedSuccessResult.first).toEqual(successResult.first); expect(cachedSuccessResult.second).toEqual(successResult.first); expect(cachedSuccessResult.cacheExists).toBe(true); - expect(cachedSuccessResult.requestCount).toBe(1); + expect(cachedSuccessResult.requestCount).toBe(0); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + harness.cleanup(); + } + }); + + it('reuses stale cached data during a numeric Retry-After backoff and retries after expiry', () => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('rate-limited-with-cache'); + const rateLimitNowMs = nowMs + 31000; + const successResult = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'success', + nowMs, + pathDir: home.bin, + responseBody: successResponseBody + }); + + const rateLimitedResult = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'status', + nowMs: rateLimitNowMs, + pathDir: home.bin, + responseBody: rateLimitedResponseBody, + responseHeaders: { 'retry-after': '3600' }, + statusCode: 429 + }); + + expect(rateLimitedResult.first).toEqual(successResult.first); + expect(rateLimitedResult.second).toEqual(successResult.first); + expect(rateLimitedResult.requestCount).toBe(1); + expect(parseLockContents(rateLimitedResult.lockContents)).toEqual({ + blockedUntil: Math.floor(rateLimitNowMs / 1000) + 3600, + error: 'rate-limited' + }); + + const activeBackoffResult = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'unexpected', + nowMs: rateLimitNowMs + 600000, + pathDir: home.bin + }); + + expect(activeBackoffResult.first).toEqual(successResult.first); + expect(activeBackoffResult.second).toEqual(successResult.first); + expect(activeBackoffResult.requestCount).toBe(0); + + const postBackoffResult = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'success', + nowMs: rateLimitNowMs + 3601000, + pathDir: home.bin, + responseBody: updatedSuccessResponseBody + }); + + expect(postBackoffResult.first).toEqual({ + sessionUsage: 55, + sessionResetAt: '2030-01-02T00:00:00.000Z', + weeklyUsage: 21, + weeklyResetAt: '2030-01-08T00:00:00.000Z' + }); + expect(postBackoffResult.second).toEqual(postBackoffResult.first); + expect(postBackoffResult.requestCount).toBe(1); + } finally { + harness.cleanup(); + } + }); + + it('returns rate-limited without stale cache and falls back to the default backoff when Retry-After is invalid', () => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('rate-limited-no-cache'); + const firstRateLimitedResult = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'status', + nowMs, + pathDir: home.bin, + responseBody: rateLimitedResponseBody, + responseHeaders: { 'retry-after': 'not-a-number' }, + statusCode: 429 + }); + + expect(firstRateLimitedResult.first).toEqual({ error: 'rate-limited' }); + expect(firstRateLimitedResult.second).toEqual({ error: 'rate-limited' }); + expect(firstRateLimitedResult.requestCount).toBe(1); + expect(parseLockContents(firstRateLimitedResult.lockContents)).toEqual({ + blockedUntil: Math.floor(nowMs / 1000) + 300, + error: 'rate-limited' + }); + + const activeBackoffResult = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'unexpected', + nowMs: nowMs + 299000, + pathDir: home.bin + }); + + expect(activeBackoffResult.first).toEqual({ error: 'rate-limited' }); + expect(activeBackoffResult.second).toEqual({ error: 'rate-limited' }); + expect(activeBackoffResult.requestCount).toBe(0); + + const postBackoffResult = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'success', + nowMs: nowMs + 301000, + pathDir: home.bin, + responseBody: successResponseBody + }); + + expect(postBackoffResult.first).toEqual({ + sessionUsage: 42, + sessionResetAt: '2030-01-01T00:00:00.000Z', + weeklyUsage: 17, + weeklyResetAt: '2030-01-07T00:00:00.000Z' + }); + expect(postBackoffResult.second).toEqual(postBackoffResult.first); + expect(postBackoffResult.requestCount).toBe(1); + } finally { + harness.cleanup(); + } + }); + + it('parses HTTP-date Retry-After headers', () => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('rate-limited-http-date'); + const retryAt = new Date(nowMs + 900000).toUTCString(); + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'status', + nowMs, + pathDir: home.bin, + responseBody: rateLimitedResponseBody, + responseHeaders: { 'retry-after': retryAt }, + statusCode: 429 + }); + + expect(result.first).toEqual({ error: 'rate-limited' }); + expect(result.second).toEqual({ error: 'rate-limited' }); + expect(parseLockContents(result.lockContents)).toEqual({ + blockedUntil: Math.floor((nowMs + 900000) / 1000), + error: 'rate-limited' + }); + } finally { + harness.cleanup(); + } + }); + + it('supports the legacy empty lock file fallback', () => { + const harness = createProbeHarness(); + + try { + const home = harness.createTokenHome('legacy-lock'); + const lockDir = path.join(home.home, '.cache', 'ccstatusline'); + const lockFile = path.join(lockDir, 'usage.lock'); + + fs.mkdirSync(lockDir, { recursive: true }); + fs.writeFileSync(lockFile, ''); + fs.utimesSync(lockFile, new Date(nowMs), new Date(nowMs)); + + const result = harness.runProbe({ + claudeConfigDir: home.claudeConfig, + home: home.home, + mode: 'unexpected', + nowMs, + pathDir: home.bin + }); + + expect(result.first).toEqual({ error: 'timeout' }); + expect(result.second).toEqual({ error: 'timeout' }); + expect(result.requestCount).toBe(0); + } finally { + harness.cleanup(); } }); }); \ No newline at end of file diff --git a/src/utils/__tests__/usage-windows.test.ts b/src/utils/__tests__/usage-windows.test.ts new file mode 100644 index 0000000..344c546 --- /dev/null +++ b/src/utils/__tests__/usage-windows.test.ts @@ -0,0 +1,13 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { getUsageErrorMessage } from '../usage-windows'; + +describe('getUsageErrorMessage', () => { + it('returns the rate-limited label', () => { + expect(getUsageErrorMessage('rate-limited')).toBe('[Rate limited]'); + }); +}); \ No newline at end of file diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts index eee8087..bb78e0c 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -19,9 +19,15 @@ const CACHE_FILE = path.join(CACHE_DIR, 'usage.json'); const LOCK_FILE = path.join(CACHE_DIR, 'usage.lock'); const CACHE_MAX_AGE = 180; // seconds const LOCK_MAX_AGE = 30; // rate limit: only try API once per 30 seconds +const DEFAULT_RATE_LIMIT_BACKOFF = 300; // seconds const TOKEN_CACHE_MAX_AGE = 3600; // 1 hour const UsageCredentialsSchema = z.object({ claudeAiOauth: z.object({ accessToken: z.string().nullable().optional() }).optional() }); +const UsageLockErrorSchema = z.enum(['timeout', 'rate-limited']); +const UsageLockSchema = z.object({ + blockedUntil: z.number(), + error: UsageLockErrorSchema.optional() +}); const CachedUsageDataSchema = z.object({ sessionUsage: z.number().nullable().optional(), @@ -110,22 +116,39 @@ let cachedUsageData: UsageData | null = null; let usageCacheTime = 0; let cachedUsageToken: string | null = null; let usageTokenCacheTime = 0; +let usageErrorCacheMaxAge = LOCK_MAX_AGE; -function setCachedUsageError(error: UsageError, now: number): UsageData { +type UsageLockError = z.infer; + +type UsageApiFetchResult = { kind: 'success'; body: string } | { kind: 'rate-limited'; retryAfterSeconds: number } | { kind: 'error' }; + +function ensureCacheDirExists(): void { + if (!fs.existsSync(CACHE_DIR)) { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + } +} + +function setCachedUsageError(error: UsageError, now: number, maxAge = LOCK_MAX_AGE): UsageData { const errorData: UsageData = { error }; cachedUsageData = errorData; usageCacheTime = now; + usageErrorCacheMaxAge = maxAge; return errorData; } -function getStaleUsageOrError(error: UsageError, now: number): UsageData { +function cacheUsageData(data: UsageData, now: number): UsageData { + cachedUsageData = data; + usageCacheTime = now; + usageErrorCacheMaxAge = LOCK_MAX_AGE; + return data; +} + +function getStaleUsageOrError(error: UsageError, now: number, errorCacheMaxAge = LOCK_MAX_AGE): UsageData { const stale = readStaleUsageCache(); if (stale && !stale.error) { - cachedUsageData = stale; - usageCacheTime = now; - return stale; + return cacheUsageData(stale, now); } - return setCachedUsageError(error, now); + return setCachedUsageError(error, now, errorCacheMaxAge); } function getUsageToken(): string | null { @@ -173,6 +196,76 @@ function readStaleUsageCache(): UsageData | null { } } +function writeUsageLock(blockedUntil: number, error: UsageLockError): void { + try { + ensureCacheDirExists(); + fs.writeFileSync(LOCK_FILE, JSON.stringify({ blockedUntil, error })); + } catch { + // Ignore lock file errors + } +} + +function readActiveUsageLock(now: number): { blockedUntil: number; error: UsageLockError } | null { + let hasValidJsonLock = false; + + try { + const parsed = parseJsonWithSchema(fs.readFileSync(LOCK_FILE, 'utf8'), UsageLockSchema); + if (parsed) { + hasValidJsonLock = true; + if (parsed.blockedUntil > now) { + return { + blockedUntil: parsed.blockedUntil, + error: parsed.error ?? 'timeout' + }; + } + return null; + } + } catch { + // Fall back to the legacy mtime-based lock behavior below. + } + + if (hasValidJsonLock) { + return null; + } + + try { + const lockStat = fs.statSync(LOCK_FILE); + const lockMtime = Math.floor(lockStat.mtimeMs / 1000); + const blockedUntil = lockMtime + LOCK_MAX_AGE; + if (blockedUntil > now) { + return { + blockedUntil, + error: 'timeout' + }; + } + } catch { + // Lock file doesn't exist - OK to proceed + } + + return null; +} + +function parseRetryAfterSeconds(headerValue: string | string[] | undefined, nowMs = Date.now()): number | null { + const rawValue = Array.isArray(headerValue) ? headerValue[0] : headerValue; + const trimmedValue = rawValue?.trim(); + if (!trimmedValue) { + return null; + } + + if (/^\d+$/.test(trimmedValue)) { + const seconds = Number.parseInt(trimmedValue, 10); + return seconds > 0 ? seconds : null; + } + + const retryAtMs = Date.parse(trimmedValue); + if (Number.isNaN(retryAtMs)) { + return null; + } + + const retryAfterSeconds = Math.ceil((retryAtMs - nowMs) / 1000); + return retryAfterSeconds > 0 ? retryAfterSeconds : null; +} + const USAGE_API_HOST = 'api.anthropic.com'; const USAGE_API_PATH = '/api/oauth/usage'; const USAGE_API_TIMEOUT_MS = 5000; @@ -206,11 +299,11 @@ function getUsageApiRequestOptions(token: string): https.RequestOptions | null { } } -async function fetchFromUsageApi(token: string): Promise { +async function fetchFromUsageApi(token: string): Promise { return new Promise((resolve) => { let settled = false; - const finish = (value: string | null) => { + const finish = (value: UsageApiFetchResult) => { if (settled) { return; } @@ -220,7 +313,7 @@ async function fetchFromUsageApi(token: string): Promise { const requestOptions = getUsageApiRequestOptions(token); if (!requestOptions) { - finish(null); + finish({ kind: 'error' }); return; } @@ -234,17 +327,26 @@ async function fetchFromUsageApi(token: string): Promise { response.on('end', () => { if (response.statusCode === 200 && data) { - finish(data); + finish({ kind: 'success', body: data }); return; } - finish(null); + + if (response.statusCode === 429) { + finish({ + kind: 'rate-limited', + retryAfterSeconds: parseRetryAfterSeconds(response.headers['retry-after']) ?? DEFAULT_RATE_LIMIT_BACKOFF + }); + return; + } + + finish({ kind: 'error' }); }); }); - request.on('error', () => { finish(null); }); + request.on('error', () => { finish({ kind: 'error' }); }); request.on('timeout', () => { request.destroy(); - finish(null); + finish({ kind: 'error' }); }); request.end(); }); @@ -259,7 +361,7 @@ export async function fetchUsageData(): Promise { if (!cachedUsageData.error && cacheAge < CACHE_MAX_AGE) { return cachedUsageData; } - if (cachedUsageData.error && cacheAge < LOCK_MAX_AGE) { + if (cachedUsageData.error && cacheAge < usageErrorCacheMaxAge) { return cachedUsageData; } } @@ -271,9 +373,7 @@ export async function fetchUsageData(): Promise { if (fileAge < CACHE_MAX_AGE) { const fileData = parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8')); if (fileData && !fileData.error) { - cachedUsageData = fileData; - usageCacheTime = now; - return fileData; + return cacheUsageData(fileData, now); } } } catch { @@ -286,41 +386,31 @@ export async function fetchUsageData(): Promise { return getStaleUsageOrError('no-credentials', now); } - // Rate limit: only try API once per 30 seconds - try { - const lockStat = fs.statSync(LOCK_FILE); - const lockAge = now - Math.floor(lockStat.mtimeMs / 1000); - if (lockAge < LOCK_MAX_AGE) { - // Rate limited - return stale cache or timeout error - const stale = readStaleUsageCache(); - if (stale && !stale.error) - return stale; - return { error: 'timeout' }; - } - } catch { - // Lock file doesn't exist - OK to proceed + const activeLock = readActiveUsageLock(now); + if (activeLock) { + return getStaleUsageOrError( + activeLock.error, + now, + Math.max(1, activeLock.blockedUntil - now) + ); } - // Touch lock file - try { - const lockDir = path.dirname(LOCK_FILE); - if (!fs.existsSync(lockDir)) { - fs.mkdirSync(lockDir, { recursive: true }); - } - fs.writeFileSync(LOCK_FILE, ''); - } catch { - // Ignore lock file errors - } + writeUsageLock(now + LOCK_MAX_AGE, 'timeout'); // Fetch from API using Node's https module try { const response = await fetchFromUsageApi(token); - if (!response) { + if (response.kind === 'rate-limited') { + writeUsageLock(now + response.retryAfterSeconds, 'rate-limited'); + return getStaleUsageOrError('rate-limited', now, response.retryAfterSeconds); + } + + if (response.kind === 'error') { return getStaleUsageOrError('api-error', now); } - const usageData = parseUsageApiResponse(response); + const usageData = parseUsageApiResponse(response.body); if (!usageData) { return getStaleUsageOrError('parse-error', now); } @@ -332,17 +422,13 @@ export async function fetchUsageData(): Promise { // Save to cache try { - if (!fs.existsSync(CACHE_DIR)) { - fs.mkdirSync(CACHE_DIR, { recursive: true }); - } + ensureCacheDirExists(); fs.writeFileSync(CACHE_FILE, JSON.stringify(usageData)); } catch { // Ignore cache write errors } - cachedUsageData = usageData; - usageCacheTime = now; - return usageData; + return cacheUsageData(usageData, now); } catch { return getStaleUsageOrError('parse-error', now); } diff --git a/src/utils/usage-types.ts b/src/utils/usage-types.ts index 7b22a35..18f4765 100644 --- a/src/utils/usage-types.ts +++ b/src/utils/usage-types.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const FIVE_HOUR_BLOCK_MS = 5 * 60 * 60 * 1000; export const SEVEN_DAY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; -export const UsageErrorSchema = z.enum(['no-credentials', 'timeout', 'api-error', 'parse-error']); +export const UsageErrorSchema = z.enum(['no-credentials', 'timeout', 'rate-limited', 'api-error', 'parse-error']); export type UsageError = z.infer; export interface UsageData { diff --git a/src/utils/usage-windows.ts b/src/utils/usage-windows.ts index 11a2522..2d469c0 100644 --- a/src/utils/usage-windows.ts +++ b/src/utils/usage-windows.ts @@ -109,6 +109,7 @@ export function getUsageErrorMessage(error: UsageError): string { switch (error) { case 'no-credentials': return '[No credentials]'; case 'timeout': return '[Timeout]'; + case 'rate-limited': return '[Rate limited]'; case 'api-error': return '[API Error]'; case 'parse-error': return '[Parse Error]'; }