From ba12896a37164e90d35c2f54d622d361d0a86aa8 Mon Sep 17 00:00:00 2001 From: luisfelipe-alt Date: Fri, 12 Jun 2026 18:59:21 +0000 Subject: [PATCH] fix(core): Ensure zero-quota limits fail fast to prevent retry loop hang (#27698) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../core/src/utils/googleQuotaErrors.test.ts | 119 ++++++++++++++++++ packages/core/src/utils/googleQuotaErrors.ts | 38 ++++-- 2 files changed, 148 insertions(+), 9 deletions(-) diff --git a/packages/core/src/utils/googleQuotaErrors.test.ts b/packages/core/src/utils/googleQuotaErrors.test.ts index 72cc47ff1e..fdef9beead 100644 --- a/packages/core/src/utils/googleQuotaErrors.test.ts +++ b/packages/core/src/utils/googleQuotaErrors.test.ts @@ -806,4 +806,123 @@ describe('classifyGoogleError', () => { const result = classifyGoogleError(new Error()); expect(result).toBeInstanceOf(ValidationRequiredError); }); + + it('should return TerminalQuotaError when limit is 0 even if message contains "Please retry in Xs"', () => { + const complexError = { + error: { + message: + '{"error": {"code": 429, "status": 429, "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, head to: https://ai.google.dev/gemini-api/docs/rate-limits. To monitor your current usage, head to: https://ai.dev/usage?tab=rate-limit. \\n* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_requests, limit: 0\\nPlease retry in 59.906331105s.", "details": [{"detail": "??? to (unknown) : APP_ERROR(8) You exceeded your current quota, please check your plan and billing details. For more information on this error, head to: https://ai.google.dev/gemini-api/docs/rate-limits. To monitor your current usage, head to: https://ai.dev/usage?tab=rate-limit. \\n* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_requests, limit: 0\\nPlease retry in 59.906331105s."}]}}', + code: 429, + status: 'Too Many Requests', + }, + }; + const rawError = new Error(JSON.stringify(complexError)) as Error & { + status?: number; + }; + rawError.status = 429; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(null); + + const result = classifyGoogleError(rawError); + + expect(result).toBeInstanceOf(TerminalQuotaError); + }); + + it('should return TerminalQuotaError when limit is 0 even if structured RetryInfo is present', () => { + const apiError: GoogleApiError = { + code: 429, + message: 'Quota exceeded for limit: 0', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.RetryInfo', + retryDelay: '59s', + }, + ], + }; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError); + const result = classifyGoogleError( + new Error('Quota exceeded for limit: 0'), + ); + expect(result).toBeInstanceOf(TerminalQuotaError); + }); + + it('should return TerminalQuotaError when limit is 0 and message contains actual newlines', () => { + const apiError: GoogleApiError = { + code: 429, + message: 'Quota exceeded for metric: ...\nlimit: 0, model: gemini-3-pro', + details: [], + }; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError); + const result = classifyGoogleError( + new Error( + 'Quota exceeded for metric: ...\nlimit: 0, model: gemini-3-pro', + ), + ); + expect(result).toBeInstanceOf(TerminalQuotaError); + }); + + it('should return TerminalQuotaError when limit is 0 followed by a period', () => { + const apiError: GoogleApiError = { + code: 429, + message: 'Quota exceeded for metric: ...\nlimit: 0. Please retry in 59s.', + details: [], + }; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError); + const result = classifyGoogleError( + new Error( + 'Quota exceeded for metric: ...\nlimit: 0. Please retry in 59s.', + ), + ); + expect(result).toBeInstanceOf(TerminalQuotaError); + }); + + it('should return RetryableQuotaError when limit is fractional (e.g., 0.5)', () => { + const apiError: GoogleApiError = { + code: 429, + message: + 'Quota exceeded for metric: ...\nlimit: 0.5. Please retry in 59s.', + details: [], + }; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError); + const result = classifyGoogleError( + new Error( + 'Quota exceeded for metric: ...\nlimit: 0.5. Please retry in 59s.', + ), + ); + expect(result).toBeInstanceOf(RetryableQuotaError); + }); + + it('should fall back to "Model not found" for 404 error with plain object', () => { + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(null); + const result = classifyGoogleError({ status: 404 }); + expect(result).toBeInstanceOf(ModelNotFoundError); + expect((result as ModelNotFoundError).message).toBe('Model not found'); + }); + + it('should parse custom 404 message from plain object correctly', () => { + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(null); + const result = classifyGoogleError({ + status: 404, + message: 'Custom 404 message', + }); + expect(result).toBeInstanceOf(ModelNotFoundError); + expect((result as ModelNotFoundError).message).toBe('Custom 404 message'); + }); + + it('should classify plain object with limit: 0 message as TerminalQuotaError correctly', () => { + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(null); + const result = classifyGoogleError({ + status: 429, + message: 'Quota exceeded, limit: 0', + }); + expect(result).toBeInstanceOf(TerminalQuotaError); + }); + + it('should handle Error instances with undefined message gracefully', () => { + const malformedError = new Error(); + delete (malformedError as { message?: string }).message; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(null); + + const result = classifyGoogleError(malformedError); + expect(result).toBe(malformedError); // Should return the original error without crashing + }); }); diff --git a/packages/core/src/utils/googleQuotaErrors.ts b/packages/core/src/utils/googleQuotaErrors.ts index ce7a88b302..ac8c76d9e8 100644 --- a/packages/core/src/utils/googleQuotaErrors.ts +++ b/packages/core/src/utils/googleQuotaErrors.ts @@ -219,11 +219,10 @@ function classifyValidationRequiredError( export function classifyGoogleError(error: unknown): unknown { const googleApiError = parseGoogleApiError(error); const status = googleApiError?.code ?? getErrorStatus(error); + const errorMessage = googleApiError?.message || extractErrorMessage(error); if (status === 404) { - const message = - googleApiError?.message || - (error instanceof Error ? error.message : 'Model not found'); + const message = errorMessage.trim() || 'Model not found'; return new ModelNotFoundError(message, status); } @@ -235,6 +234,20 @@ export function classifyGoogleError(error: unknown): unknown { } } + // Universal limit: 0 check (moved outside and before the fallback block) + const lowerMessage = errorMessage.toLowerCase(); + if ( + (status === 429 || status === 499 || status === 503) && + /limit:\s*0(?!\d|\.\d)/.test(lowerMessage) + ) { + const cause = googleApiError ?? { + code: status ?? 429, + message: errorMessage, + details: [], + }; + return new TerminalQuotaError(errorMessage, cause); + } + if ( !googleApiError || (googleApiError.code !== 429 && @@ -243,9 +256,6 @@ export function classifyGoogleError(error: unknown): unknown { googleApiError.details.length === 0 ) { // Fallback: try to parse the error message for a retry delay - const errorMessage = - googleApiError?.message || - (error instanceof Error ? error.message : String(error)); const match = errorMessage.match(/Please retry in ([0-9.]+(?:ms|s))/); if (match?.[1]) { const retryDelaySeconds = parseDurationInSeconds(match[1]); @@ -394,8 +404,18 @@ export function classifyGoogleError(error: unknown): unknown { // If we reached this point, the status is 429, 499, or 503 and we have details, // but no specific violation was matched. We return a generic retryable error. - const errorMessage = - googleApiError.message || - (error instanceof Error ? error.message : String(error)); return new RetryableQuotaError(errorMessage, googleApiError); } + +function extractErrorMessage(error: unknown): string { + if (typeof error === 'string') { + return error; + } + if (typeof error === 'object' && error !== null && 'message' in error) { + const msg = (error as { message: unknown }).message; + if (typeof msg === 'string') { + return msg; + } + } + return ''; +}