diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index 878dbe2d..88ac119a 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -14,6 +14,8 @@ import type { CodexQuotaResult, CodexQuotaWindow } from './quota-types'; /** ChatGPT backend API base URL */ const CODEX_API_BASE = 'https://chatgpt.com/backend-api'; +const CODEX_QUOTA_TIMEOUT_MS = 12000; +const CODEX_QUOTA_MAX_ATTEMPTS = 2; /** * User agent matching Codex CLI for API compatibility. @@ -222,114 +224,134 @@ export async function fetchCodexQuota( } const url = `${CODEX_API_BASE}/wham/usage`; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); + let lastErrorMsg = 'Unknown error'; - try { - const response = await fetch(url, { - method: 'GET', - signal: controller.signal, - headers: { - Authorization: `Bearer ${authData.accessToken}`, - 'ChatGPT-Account-Id': authData.accountId, - 'User-Agent': USER_AGENT, - }, - }); + for (let attempt = 1; attempt <= CODEX_QUOTA_MAX_ATTEMPTS; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), CODEX_QUOTA_TIMEOUT_MS); - clearTimeout(timeoutId); + try { + const response = await fetch(url, { + method: 'GET', + signal: controller.signal, + headers: { + Authorization: `Bearer ${authData.accessToken}`, + 'ChatGPT-Account-Id': authData.accountId, + 'User-Agent': USER_AGENT, + }, + }); - if (verbose) console.error(`[i] Codex API status: ${response.status}`); + clearTimeout(timeoutId); + + if (verbose) console.error(`[i] Codex API status: ${response.status} (attempt ${attempt})`); + + if (response.status === 401) { + return { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + error: 'Token expired or invalid', + accountId, + needsReauth: true, + }; + } + + if (response.status === 403) { + // 403 = account lacks API access (not same as quota exhausted) + // Keep success=false with isForbidden flag for UI to show distinct "403" badge + return { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + error: '403 Forbidden - No quota API access', + accountId, + isForbidden: true, + }; + } + + if (response.status === 429) { + return { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + error: 'Rate limited - try again later', + accountId, + }; + } + + if (!response.ok) { + return { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + error: `API error: ${response.status}`, + accountId, + }; + } + + const data = (await response.json()) as CodexUsageResponse; + const windows = buildCodexQuotaWindows(data); + + // Extract plan type + const planTypeRaw = data.plan_type || data.planType; + let planType: 'free' | 'plus' | 'team' | null = null; + if (planTypeRaw) { + const normalized = planTypeRaw.toLowerCase(); + if (normalized === 'free') planType = 'free'; + else if (normalized === 'plus') planType = 'plus'; + else if (normalized === 'team') planType = 'team'; + } + + if (verbose) console.error(`[i] Codex windows found: ${windows.length}`); - if (response.status === 401) { return { - success: false, - windows: [], - planType: null, + success: true, + windows, + planType, lastUpdated: Date.now(), - error: 'Token expired or invalid', - accountId, - needsReauth: true, - }; - } - - if (response.status === 403) { - // 403 = account lacks API access (not same as quota exhausted) - // Keep success=false with isForbidden flag for UI to show distinct "403" badge - return { - success: false, - windows: [], - planType: null, - lastUpdated: Date.now(), - error: '403 Forbidden - No quota API access', - accountId, - isForbidden: true, - }; - } - - if (response.status === 429) { - return { - success: false, - windows: [], - planType: null, - lastUpdated: Date.now(), - error: 'Rate limited - try again later', accountId, }; - } - - if (!response.ok) { - return { - success: false, - windows: [], - planType: null, - lastUpdated: Date.now(), - error: `API error: ${response.status}`, - accountId, - }; - } - - const data = (await response.json()) as CodexUsageResponse; - const windows = buildCodexQuotaWindows(data); - - // Extract plan type - const planTypeRaw = data.plan_type || data.planType; - let planType: 'free' | 'plus' | 'team' | null = null; - if (planTypeRaw) { - const normalized = planTypeRaw.toLowerCase(); - if (normalized === 'free') planType = 'free'; - else if (normalized === 'plus') planType = 'plus'; - else if (normalized === 'team') planType = 'team'; - } - - if (verbose) console.error(`[i] Codex windows found: ${windows.length}`); - - return { - success: true, - windows, - planType, - lastUpdated: Date.now(), - accountId, - }; - } catch (err) { - clearTimeout(timeoutId); - const errorMsg = - err instanceof Error && err.name === 'AbortError' + } catch (err) { + clearTimeout(timeoutId); + const isAbortError = err instanceof Error && err.name === 'AbortError'; + lastErrorMsg = isAbortError ? 'Request timeout' : err instanceof Error ? err.message : 'Unknown error'; - if (verbose) console.error(`[!] Codex quota error: ${errorMsg}`); + if (verbose) { + console.error(`[!] Codex quota error (attempt ${attempt}): ${lastErrorMsg}`); + } - return { - success: false, - windows: [], - planType: null, - lastUpdated: Date.now(), - error: errorMsg, - accountId, - }; + // Retry timeout once; other failures return immediately. + if (isAbortError && attempt < CODEX_QUOTA_MAX_ATTEMPTS) { + continue; + } + + return { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + error: lastErrorMsg, + accountId, + }; + } } + + return { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + error: lastErrorMsg, + accountId, + }; } /** diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index bae7f14c..4fd41249 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -46,6 +46,37 @@ import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; const router = Router(); +/** + * Cache only stable failures; avoid pinning transient network failures (timeouts, 429s). + */ +function shouldCacheCodexQuotaResult(result: CodexQuotaResult): boolean { + if (result.success) return true; + if (result.needsReauth || result.isForbidden) return true; + + const msg = (result.error || '').toLowerCase(); + if (!msg) return false; + if (msg.includes('timeout')) return false; + if (msg.includes('rate limited')) return false; + if (msg.includes('api error: 5')) return false; + if (msg.includes('fetch failed')) return false; + + return false; +} + +function shouldCacheGeminiQuotaResult(result: GeminiCliQuotaResult): boolean { + if (result.success) return true; + if (result.needsReauth) return true; + + const msg = (result.error || '').toLowerCase(); + if (!msg) return false; + if (msg.includes('timeout')) return false; + if (msg.includes('rate limited')) return false; + if (msg.includes('api error: 5')) return false; + if (msg.includes('fetch failed')) return false; + + return false; +} + /** Get configured backend from config */ function getConfiguredBackend() { try { @@ -548,8 +579,8 @@ router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promi // Fetch from external API const result = await fetchCodexQuota(accountId); - // Cache successful results (don't cache errors that need reauth) - if (result.success || !result.needsReauth) { + // Cache successful and stable failure states; skip transient network failures. + if (shouldCacheCodexQuotaResult(result)) { setCachedQuota('codex', accountId, result); } @@ -589,8 +620,8 @@ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Prom // Fetch from external API const result = await fetchGeminiCliQuota(accountId); - // Cache successful results (don't cache errors that need reauth) - if (result.success || !result.needsReauth) { + // Cache successful and stable failure states; skip transient network failures. + if (shouldCacheGeminiQuotaResult(result)) { setCachedQuota('gemini', accountId, result); }