diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 35bb6a355..0167d8ab1 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog ## [Unreleased] + +### Changed + +- Implemented time-budget-based retry strategy for Google Gemini rate limits (429 errors), allowing retries within a 5-minute window instead of a fixed attempt cap +- Set default `maxRetries` to 5 for Anthropic, Azure OpenAI, and OpenAI client configurations to improve resilience against transient failures + ### Fixed - Improved error messages for OAuth token refresh failures by including detailed error information from the provider diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 9e77e1666..03ec33238 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -629,6 +629,7 @@ function createClient( baseURL: config.baseURL, defaultHeaders: config.defaultHeaders, dangerouslyAllowBrowser: config.dangerouslyAllowBrowser, + maxRetries: 5, }); return { client, isOAuthToken: config.isOAuthToken }; diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index 486a49cb5..2075b689d 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -424,6 +424,7 @@ function createClient(model: Model<"azure-openai-responses">, apiKey: string, op apiKey, apiVersion, dangerouslyAllowBrowser: true, + maxRetries: 5, defaultHeaders: headers, baseURL: baseUrl, }); diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index 481da9688..8cf459ed9 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -99,6 +99,7 @@ const MAX_RETRIES = 3; const BASE_DELAY_MS = 1000; const MAX_EMPTY_STREAM_RETRIES = 2; const EMPTY_STREAM_BASE_DELAY_MS = 500; +const RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000; const CLAUDE_THINKING_BETA_HEADER = "interleaved-thinking-2025-05-14"; /** @@ -360,8 +361,9 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( let response: Response | undefined; let lastError: Error | undefined; let requestUrl: string | undefined; + let rateLimitTimeSpent = 0; - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + for (let attempt = 0; ; attempt++) { if (options?.signal?.aborted) { throw new Error("Request was aborted"); } @@ -382,13 +384,25 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( const errorText = await response.text(); - // Check if retryable - if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) { - // Use server-provided delay or exponential backoff + // Handle 429 rate limits with time budget + if (response.status === 429) { + const serverDelay = extractRetryDelay(errorText, response); + if (serverDelay && rateLimitTimeSpent + serverDelay <= RATE_LIMIT_BUDGET_MS) { + rateLimitTimeSpent += serverDelay; + await abortableSleep(serverDelay, options?.signal); + continue; + } + // Fallback: use exponential backoff if no server delay, up to MAX_RETRIES + if (!serverDelay && attempt < MAX_RETRIES) { + await abortableSleep(BASE_DELAY_MS * 2 ** attempt, options?.signal); + continue; + } + } else if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) { + // Non-429 retryable errors use standard attempt cap const serverDelay = extractRetryDelay(errorText, response); const delayMs = serverDelay ?? BASE_DELAY_MS * 2 ** attempt; - // Check if server delay exceeds max allowed (default: 60s) + // Check if server delay exceeds max allowed (default: 60s) for non-429 errors const maxDelayMs = options?.maxRetryDelayMs ?? 60000; if (maxDelayMs > 0 && serverDelay && serverDelay > maxDelayMs) { const delaySeconds = Math.ceil(serverDelay / 1000); @@ -401,7 +415,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( continue; } - // Not retryable or max retries exceeded + // Not retryable or budget exceeded throw new Error(`Cloud Code Assist API error (${response.status}): ${extractErrorMessage(errorText)}`); } catch (error) { // Check for abort - fetch throws AbortError, our code throws "Request was aborted" diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 810f64b73..cc94c5bb3 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -480,6 +480,7 @@ async function createClient( apiKey, baseURL: model.baseUrl, dangerouslyAllowBrowser: true, + maxRetries: 5, defaultHeaders: headers, }); } diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 833e9236a..20c2f9054 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -397,6 +397,7 @@ function createClient( apiKey, baseURL: model.baseUrl, dangerouslyAllowBrowser: true, + maxRetries: 5, defaultHeaders: headers, }); }