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>
This commit is contained in:
luisfelipe-alt
2026-06-12 18:59:21 +00:00
committed by GitHub
parent 4e10a34be8
commit ba12896a37
2 changed files with 148 additions and 9 deletions
@@ -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
});
});
+29 -9
View File
@@ -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 '';
}