antigravity-proxy / src /cloudcode /rate-limit-parser.js
Yash030's picture
Initial Commit
d613519
/**
* Rate Limit Parser for Cloud Code
*
* Parses reset times from HTTP headers and error messages.
* Supports various formats: Retry-After, x-ratelimit-reset,
* quotaResetDelay, quotaResetTimeStamp, and duration strings.
*/
import { formatDuration } from '../utils/helpers.js';
import { logger } from '../utils/logger.js';
/**
* Parse reset time from HTTP response or error
* Checks headers first, then error message body
* Returns milliseconds or null if not found
*
* @param {Response|Error} responseOrError - HTTP Response object or Error
* @param {string} errorText - Optional error body text
*/
export function parseResetTime(responseOrError, errorText = '') {
let resetMs = null;
// If it's a Response object, check headers first
if (responseOrError && typeof responseOrError.headers?.get === 'function') {
const headers = responseOrError.headers;
// Standard Retry-After header (seconds or HTTP date)
const retryAfter = headers.get('retry-after');
if (retryAfter) {
const seconds = parseInt(retryAfter, 10);
if (!isNaN(seconds)) {
resetMs = seconds * 1000;
logger.debug(`[CloudCode] Retry-After header: ${seconds}s`);
} else {
// Try parsing as HTTP date
const date = new Date(retryAfter);
if (!isNaN(date.getTime())) {
resetMs = date.getTime() - Date.now();
if (resetMs > 0) {
logger.debug(`[CloudCode] Retry-After date: ${retryAfter}`);
} else {
resetMs = null;
}
}
}
}
// x-ratelimit-reset (Unix timestamp in seconds)
if (!resetMs) {
const ratelimitReset = headers.get('x-ratelimit-reset');
if (ratelimitReset) {
const resetTimestamp = parseInt(ratelimitReset, 10) * 1000;
resetMs = resetTimestamp - Date.now();
if (resetMs > 0) {
logger.debug(`[CloudCode] x-ratelimit-reset: ${new Date(resetTimestamp).toISOString()}`);
} else {
resetMs = null;
}
}
}
// x-ratelimit-reset-after (seconds)
if (!resetMs) {
const resetAfter = headers.get('x-ratelimit-reset-after');
if (resetAfter) {
const seconds = parseInt(resetAfter, 10);
if (!isNaN(seconds) && seconds > 0) {
resetMs = seconds * 1000;
logger.debug(`[CloudCode] x-ratelimit-reset-after: ${seconds}s`);
}
}
}
}
// If no header found, try parsing from error message/body
if (!resetMs) {
const msg = (responseOrError instanceof Error ? responseOrError.message : errorText) || '';
// Try to extract "quotaResetDelay" first (e.g. "754.431528ms" or "1.5s")
// This is Google's preferred format for rate limit reset delay
const quotaDelayMatch = msg.match(/quotaResetDelay[:\s"]+(\d+(?:\.\d+)?)(ms|s)/i);
if (quotaDelayMatch) {
const value = parseFloat(quotaDelayMatch[1]);
const unit = quotaDelayMatch[2].toLowerCase();
resetMs = unit === 's' ? Math.ceil(value * 1000) : Math.ceil(value);
logger.debug(`[CloudCode] Parsed quotaResetDelay from body: ${resetMs}ms`);
}
// Try to extract "quotaResetTimeStamp" (ISO format like "2025-12-31T07:00:47Z")
if (!resetMs) {
const quotaTimestampMatch = msg.match(/quotaResetTimeStamp[:\s"]+(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)/i);
if (quotaTimestampMatch) {
const resetTime = new Date(quotaTimestampMatch[1]).getTime();
if (!isNaN(resetTime)) {
resetMs = resetTime - Date.now();
// Even if expired or 0, we found a timestamp, so rely on it.
// But if it's negative, it means "now", so treat as small wait.
logger.debug(`[CloudCode] Parsed quotaResetTimeStamp: ${quotaTimestampMatch[1]} (Delta: ${resetMs}ms)`);
}
}
}
// Try to extract "retry-after-ms" or "retryDelay" - check seconds format first (e.g. "7739.23s")
// Added stricter regex to avoid partial matches
if (!resetMs) {
const secMatch = msg.match(/(?:retry[-_]?after[-_]?ms|retryDelay)[:\s"]+([\d.]+)(?:s\b|s")/i);
if (secMatch) {
resetMs = Math.ceil(parseFloat(secMatch[1]) * 1000);
logger.debug(`[CloudCode] Parsed retry seconds from body (precise): ${resetMs}ms`);
}
}
if (!resetMs) {
// Check for ms (explicit "ms" suffix or implicit if no suffix)
const msMatch = msg.match(/(?:retry[-_]?after[-_]?ms|retryDelay)[:\s"]+(\d+)(?:\s*ms)?(?![\w.])/i);
if (msMatch) {
resetMs = parseInt(msMatch[1], 10);
logger.debug(`[CloudCode] Parsed retry-after-ms from body: ${resetMs}ms`);
}
}
// Try to extract seconds value like "retry after 60 seconds"
if (!resetMs) {
const secMatch = msg.match(/retry\s+(?:after\s+)?(\d+)\s*(?:sec|s\b)/i);
if (secMatch) {
resetMs = parseInt(secMatch[1], 10) * 1000;
logger.debug(`[CloudCode] Parsed retry seconds from body: ${secMatch[1]}s`);
}
}
// Try to extract duration like "1h23m45s" or "23m45s" or "45s"
if (!resetMs) {
const durationMatch = msg.match(/(\d+)h(\d+)m(\d+)s|(\d+)m(\d+)s|(\d+)s/i);
if (durationMatch) {
if (durationMatch[1]) {
const hours = parseInt(durationMatch[1], 10);
const minutes = parseInt(durationMatch[2], 10);
const seconds = parseInt(durationMatch[3], 10);
resetMs = (hours * 3600 + minutes * 60 + seconds) * 1000;
} else if (durationMatch[4]) {
const minutes = parseInt(durationMatch[4], 10);
const seconds = parseInt(durationMatch[5], 10);
resetMs = (minutes * 60 + seconds) * 1000;
} else if (durationMatch[6]) {
resetMs = parseInt(durationMatch[6], 10) * 1000;
}
if (resetMs) {
logger.debug(`[CloudCode] Parsed duration from body: ${formatDuration(resetMs)}`);
}
}
}
// Try to extract ISO timestamp or Unix timestamp
if (!resetMs) {
const isoMatch = msg.match(/reset[:\s"]+(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)/i);
if (isoMatch) {
const resetTime = new Date(isoMatch[1]).getTime();
if (!isNaN(resetTime)) {
resetMs = resetTime - Date.now();
if (resetMs > 0) {
logger.debug(`[CloudCode] Parsed ISO reset time: ${isoMatch[1]}`);
} else {
resetMs = null;
}
}
}
}
}
// SANITY CHECK: Handle very small or negative reset times
// For sub-second rate limits (common with per-second quotas), add a small buffer
// For negative or zero, use a reasonable minimum
if (resetMs !== null) {
if (resetMs <= 0) {
logger.debug(`[CloudCode] Reset time invalid (${resetMs}ms), using 500ms default`);
resetMs = 500;
} else if (resetMs < 500) {
// Very short reset - add 200ms buffer for network latency
logger.debug(`[CloudCode] Short reset time (${resetMs}ms), adding 200ms buffer`);
resetMs = resetMs + 200;
}
// Note: No longer enforcing 2s minimum - this was causing cascading failures
// when all accounts had short rate limits simultaneously
}
return resetMs;
}
/**
* Parse the rate limit reason from error text
* Used for smart backoff by error type (matches opencode-antigravity-auth)
*
* @param {string} errorText - Error message/body text
* @param {number} [status] - HTTP status code (optional, for status-based classification)
* @returns {'RATE_LIMIT_EXCEEDED' | 'QUOTA_EXHAUSTED' | 'MODEL_CAPACITY_EXHAUSTED' | 'SERVER_ERROR' | 'UNKNOWN'} Error reason
*/
export function parseRateLimitReason(errorText, status) {
// Status code checks FIRST (matches opencode-antigravity-auth Rust parity)
// 529 = Site Overloaded, 503 = Service Unavailable → Capacity issues
if (status === 529 || status === 503) return 'MODEL_CAPACITY_EXHAUSTED';
// 500 = Internal Server Error → Treat as Server Error (soft wait)
if (status === 500) return 'SERVER_ERROR';
const lower = (errorText || '').toLowerCase();
// Check for quota exhaustion (daily/hourly limits)
if (lower.includes('quota_exhausted') ||
lower.includes('quotaresetdelay') ||
lower.includes('quotaresettimestamp') ||
lower.includes('resource_exhausted') ||
lower.includes('daily limit') ||
lower.includes('quota exceeded')) {
return 'QUOTA_EXHAUSTED';
}
// Check for model capacity issues (temporary, retry quickly)
if (lower.includes('model_capacity_exhausted') ||
lower.includes('capacity_exhausted') ||
lower.includes('model is currently overloaded') ||
lower.includes('service temporarily unavailable')) {
return 'MODEL_CAPACITY_EXHAUSTED';
}
// Check for rate limiting (per-minute limits)
if (lower.includes('rate_limit_exceeded') ||
lower.includes('rate limit') ||
lower.includes('too many requests') ||
lower.includes('throttl')) {
return 'RATE_LIMIT_EXCEEDED';
}
// Check for server errors
if (lower.includes('internal server error') ||
lower.includes('server error') ||
lower.includes('503') ||
lower.includes('502') ||
lower.includes('504')) {
return 'SERVER_ERROR';
}
return 'UNKNOWN';
}