mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 18:16:08 +00:00
fix(codex): reduce quota timeout flakes in dashboard
This commit is contained in:
@@ -14,6 +14,8 @@ import type { CodexQuotaResult, CodexQuotaWindow } from './quota-types';
|
|||||||
|
|
||||||
/** ChatGPT backend API base URL */
|
/** ChatGPT backend API base URL */
|
||||||
const CODEX_API_BASE = 'https://chatgpt.com/backend-api';
|
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.
|
* User agent matching Codex CLI for API compatibility.
|
||||||
@@ -222,114 +224,134 @@ export async function fetchCodexQuota(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = `${CODEX_API_BASE}/wham/usage`;
|
const url = `${CODEX_API_BASE}/wham/usage`;
|
||||||
const controller = new AbortController();
|
let lastErrorMsg = 'Unknown error';
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
||||||
|
|
||||||
try {
|
for (let attempt = 1; attempt <= CODEX_QUOTA_MAX_ATTEMPTS; attempt++) {
|
||||||
const response = await fetch(url, {
|
const controller = new AbortController();
|
||||||
method: 'GET',
|
const timeoutId = setTimeout(() => controller.abort(), CODEX_QUOTA_TIMEOUT_MS);
|
||||||
signal: controller.signal,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${authData.accessToken}`,
|
|
||||||
'ChatGPT-Account-Id': authData.accountId,
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
success: false,
|
success: true,
|
||||||
windows: [],
|
windows,
|
||||||
planType: null,
|
planType,
|
||||||
lastUpdated: Date.now(),
|
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,
|
accountId,
|
||||||
};
|
};
|
||||||
}
|
} catch (err) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
if (!response.ok) {
|
const isAbortError = err instanceof Error && err.name === 'AbortError';
|
||||||
return {
|
lastErrorMsg = isAbortError
|
||||||
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'
|
|
||||||
? 'Request timeout'
|
? 'Request timeout'
|
||||||
: err instanceof Error
|
: err instanceof Error
|
||||||
? err.message
|
? err.message
|
||||||
: 'Unknown error';
|
: 'Unknown error';
|
||||||
|
|
||||||
if (verbose) console.error(`[!] Codex quota error: ${errorMsg}`);
|
if (verbose) {
|
||||||
|
console.error(`[!] Codex quota error (attempt ${attempt}): ${lastErrorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
// Retry timeout once; other failures return immediately.
|
||||||
success: false,
|
if (isAbortError && attempt < CODEX_QUOTA_MAX_ATTEMPTS) {
|
||||||
windows: [],
|
continue;
|
||||||
planType: null,
|
}
|
||||||
lastUpdated: Date.now(),
|
|
||||||
error: errorMsg,
|
return {
|
||||||
accountId,
|
success: false,
|
||||||
};
|
windows: [],
|
||||||
|
planType: null,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
error: lastErrorMsg,
|
||||||
|
accountId,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
windows: [],
|
||||||
|
planType: null,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
error: lastErrorMsg,
|
||||||
|
accountId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -46,6 +46,37 @@ import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
|||||||
|
|
||||||
const router = Router();
|
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 */
|
/** Get configured backend from config */
|
||||||
function getConfiguredBackend() {
|
function getConfiguredBackend() {
|
||||||
try {
|
try {
|
||||||
@@ -548,8 +579,8 @@ router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promi
|
|||||||
// Fetch from external API
|
// Fetch from external API
|
||||||
const result = await fetchCodexQuota(accountId);
|
const result = await fetchCodexQuota(accountId);
|
||||||
|
|
||||||
// Cache successful results (don't cache errors that need reauth)
|
// Cache successful and stable failure states; skip transient network failures.
|
||||||
if (result.success || !result.needsReauth) {
|
if (shouldCacheCodexQuotaResult(result)) {
|
||||||
setCachedQuota('codex', accountId, result);
|
setCachedQuota('codex', accountId, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,8 +620,8 @@ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Prom
|
|||||||
// Fetch from external API
|
// Fetch from external API
|
||||||
const result = await fetchGeminiCliQuota(accountId);
|
const result = await fetchGeminiCliQuota(accountId);
|
||||||
|
|
||||||
// Cache successful results (don't cache errors that need reauth)
|
// Cache successful and stable failure states; skip transient network failures.
|
||||||
if (result.success || !result.needsReauth) {
|
if (shouldCacheGeminiQuotaResult(result)) {
|
||||||
setCachedQuota('gemini', accountId, result);
|
setCachedQuota('gemini', accountId, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user