File size: 10,256 Bytes
4badc3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
/**
 * 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';
}