Skip to content

Commit ba12896

Browse files
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>
1 parent 4e10a34 commit ba12896

2 files changed

Lines changed: 148 additions & 9 deletions

File tree

packages/core/src/utils/googleQuotaErrors.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,4 +806,123 @@ describe('classifyGoogleError', () => {
806806
const result = classifyGoogleError(new Error());
807807
expect(result).toBeInstanceOf(ValidationRequiredError);
808808
});
809+
810+
it('should return TerminalQuotaError when limit is 0 even if message contains "Please retry in Xs"', () => {
811+
const complexError = {
812+
error: {
813+
message:
814+
'{"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."}]}}',
815+
code: 429,
816+
status: 'Too Many Requests',
817+
},
818+
};
819+
const rawError = new Error(JSON.stringify(complexError)) as Error & {
820+
status?: number;
821+
};
822+
rawError.status = 429;
823+
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(null);
824+
825+
const result = classifyGoogleError(rawError);
826+
827+
expect(result).toBeInstanceOf(TerminalQuotaError);
828+
});
829+
830+
it('should return TerminalQuotaError when limit is 0 even if structured RetryInfo is present', () => {
831+
const apiError: GoogleApiError = {
832+
code: 429,
833+
message: 'Quota exceeded for limit: 0',
834+
details: [
835+
{
836+
'@type': 'type.googleapis.com/google.rpc.RetryInfo',
837+
retryDelay: '59s',
838+
},
839+
],
840+
};
841+
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
842+
const result = classifyGoogleError(
843+
new Error('Quota exceeded for limit: 0'),
844+
);
845+
expect(result).toBeInstanceOf(TerminalQuotaError);
846+
});
847+
848+
it('should return TerminalQuotaError when limit is 0 and message contains actual newlines', () => {
849+
const apiError: GoogleApiError = {
850+
code: 429,
851+
message: 'Quota exceeded for metric: ...\nlimit: 0, model: gemini-3-pro',
852+
details: [],
853+
};
854+
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
855+
const result = classifyGoogleError(
856+
new Error(
857+
'Quota exceeded for metric: ...\nlimit: 0, model: gemini-3-pro',
858+
),
859+
);
860+
expect(result).toBeInstanceOf(TerminalQuotaError);
861+
});
862+
863+
it('should return TerminalQuotaError when limit is 0 followed by a period', () => {
864+
const apiError: GoogleApiError = {
865+
code: 429,
866+
message: 'Quota exceeded for metric: ...\nlimit: 0. Please retry in 59s.',
867+
details: [],
868+
};
869+
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
870+
const result = classifyGoogleError(
871+
new Error(
872+
'Quota exceeded for metric: ...\nlimit: 0. Please retry in 59s.',
873+
),
874+
);
875+
expect(result).toBeInstanceOf(TerminalQuotaError);
876+
});
877+
878+
it('should return RetryableQuotaError when limit is fractional (e.g., 0.5)', () => {
879+
const apiError: GoogleApiError = {
880+
code: 429,
881+
message:
882+
'Quota exceeded for metric: ...\nlimit: 0.5. Please retry in 59s.',
883+
details: [],
884+
};
885+
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
886+
const result = classifyGoogleError(
887+
new Error(
888+
'Quota exceeded for metric: ...\nlimit: 0.5. Please retry in 59s.',
889+
),
890+
);
891+
expect(result).toBeInstanceOf(RetryableQuotaError);
892+
});
893+
894+
it('should fall back to "Model not found" for 404 error with plain object', () => {
895+
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(null);
896+
const result = classifyGoogleError({ status: 404 });
897+
expect(result).toBeInstanceOf(ModelNotFoundError);
898+
expect((result as ModelNotFoundError).message).toBe('Model not found');
899+
});
900+
901+
it('should parse custom 404 message from plain object correctly', () => {
902+
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(null);
903+
const result = classifyGoogleError({
904+
status: 404,
905+
message: 'Custom 404 message',
906+
});
907+
expect(result).toBeInstanceOf(ModelNotFoundError);
908+
expect((result as ModelNotFoundError).message).toBe('Custom 404 message');
909+
});
910+
911+
it('should classify plain object with limit: 0 message as TerminalQuotaError correctly', () => {
912+
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(null);
913+
const result = classifyGoogleError({
914+
status: 429,
915+
message: 'Quota exceeded, limit: 0',
916+
});
917+
expect(result).toBeInstanceOf(TerminalQuotaError);
918+
});
919+
920+
it('should handle Error instances with undefined message gracefully', () => {
921+
const malformedError = new Error();
922+
delete (malformedError as { message?: string }).message;
923+
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(null);
924+
925+
const result = classifyGoogleError(malformedError);
926+
expect(result).toBe(malformedError); // Should return the original error without crashing
927+
});
809928
});

packages/core/src/utils/googleQuotaErrors.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -219,11 +219,10 @@ function classifyValidationRequiredError(
219219
export function classifyGoogleError(error: unknown): unknown {
220220
const googleApiError = parseGoogleApiError(error);
221221
const status = googleApiError?.code ?? getErrorStatus(error);
222+
const errorMessage = googleApiError?.message || extractErrorMessage(error);
222223

223224
if (status === 404) {
224-
const message =
225-
googleApiError?.message ||
226-
(error instanceof Error ? error.message : 'Model not found');
225+
const message = errorMessage.trim() || 'Model not found';
227226
return new ModelNotFoundError(message, status);
228227
}
229228

@@ -235,6 +234,20 @@ export function classifyGoogleError(error: unknown): unknown {
235234
}
236235
}
237236

237+
// Universal limit: 0 check (moved outside and before the fallback block)
238+
const lowerMessage = errorMessage.toLowerCase();
239+
if (
240+
(status === 429 || status === 499 || status === 503) &&
241+
/limit:\s*0(?!\d|\.\d)/.test(lowerMessage)
242+
) {
243+
const cause = googleApiError ?? {
244+
code: status ?? 429,
245+
message: errorMessage,
246+
details: [],
247+
};
248+
return new TerminalQuotaError(errorMessage, cause);
249+
}
250+
238251
if (
239252
!googleApiError ||
240253
(googleApiError.code !== 429 &&
@@ -243,9 +256,6 @@ export function classifyGoogleError(error: unknown): unknown {
243256
googleApiError.details.length === 0
244257
) {
245258
// Fallback: try to parse the error message for a retry delay
246-
const errorMessage =
247-
googleApiError?.message ||
248-
(error instanceof Error ? error.message : String(error));
249259
const match = errorMessage.match(/Please retry in ([0-9.]+(?:ms|s))/);
250260
if (match?.[1]) {
251261
const retryDelaySeconds = parseDurationInSeconds(match[1]);
@@ -394,8 +404,18 @@ export function classifyGoogleError(error: unknown): unknown {
394404

395405
// If we reached this point, the status is 429, 499, or 503 and we have details,
396406
// but no specific violation was matched. We return a generic retryable error.
397-
const errorMessage =
398-
googleApiError.message ||
399-
(error instanceof Error ? error.message : String(error));
400407
return new RetryableQuotaError(errorMessage, googleApiError);
401408
}
409+
410+
function extractErrorMessage(error: unknown): string {
411+
if (typeof error === 'string') {
412+
return error;
413+
}
414+
if (typeof error === 'object' && error !== null && 'message' in error) {
415+
const msg = (error as { message: unknown }).message;
416+
if (typeof msg === 'string') {
417+
return msg;
418+
}
419+
}
420+
return '';
421+
}

0 commit comments

Comments
 (0)