mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-13 21:07:00 -07:00
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:
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user