Handle usage API 429 backoff

Closes #204
This commit is contained in:
Matthew Breedlove
2026-03-08 01:11:47 -05:00
parent db950f3f7a
commit dfa009e7df
7 changed files with 569 additions and 224 deletions
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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 {
+418 -173
View File
@@ -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<string, string>;
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();
}
});
});
+13
View File
@@ -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]');
});
});
+134 -48
View File
@@ -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<typeof UsageLockErrorSchema>;
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<string | null> {
async function fetchFromUsageApi(token: string): Promise<UsageApiFetchResult> {
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<string | null> {
const requestOptions = getUsageApiRequestOptions(token);
if (!requestOptions) {
finish(null);
finish({ kind: 'error' });
return;
}
@@ -234,17 +327,26 @@ async function fetchFromUsageApi(token: string): Promise<string | null> {
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<UsageData> {
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<UsageData> {
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<UsageData> {
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<UsageData> {
// 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);
}
+1 -1
View File
@@ -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<typeof UsageErrorSchema>;
export interface UsageData {
+1
View File
@@ -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]';
}