mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
fix(core): honor retryDelay in RetryInfo for 503 errors (#25057)
This commit is contained in:
@@ -81,6 +81,32 @@ describe('classifyGoogleError', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return RetryableQuotaError with delay for 503 Service Unavailable with RetryInfo', () => {
|
||||||
|
const apiError: GoogleApiError = {
|
||||||
|
code: 503,
|
||||||
|
message:
|
||||||
|
'No capacity available for model gemini-3.1-pro-preview on the server',
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||||
|
reason: 'MODEL_CAPACITY_EXHAUSTED',
|
||||||
|
domain: 'cloudcode-pa.googleapis.com',
|
||||||
|
metadata: {
|
||||||
|
model: 'gemini-3.1-pro-preview',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'type.googleapis.com/google.rpc.RetryInfo',
|
||||||
|
retryDelay: '9s',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
|
||||||
|
const result = classifyGoogleError(new Error());
|
||||||
|
expect(result).toBeInstanceOf(RetryableQuotaError);
|
||||||
|
expect((result as RetryableQuotaError).retryDelayMs).toBe(9000);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return original error if code is not 429, 499 or 503', () => {
|
it('should return original error if code is not 429, 499 or 503', () => {
|
||||||
const apiError: GoogleApiError = {
|
const apiError: GoogleApiError = {
|
||||||
code: 500,
|
code: 500,
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ import {
|
|||||||
} from './googleErrors.js';
|
} from './googleErrors.js';
|
||||||
import { getErrorStatus, ModelNotFoundError } from './httpErrors.js';
|
import { getErrorStatus, ModelNotFoundError } from './httpErrors.js';
|
||||||
|
|
||||||
|
// Enum for Google API type strings
|
||||||
|
enum GoogleApiType {
|
||||||
|
ERROR_INFO = 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||||
|
HELP = 'type.googleapis.com/google.rpc.Help',
|
||||||
|
QUOTA_FAILURE = 'type.googleapis.com/google.rpc.QuotaFailure',
|
||||||
|
RETRY_INFO = 'type.googleapis.com/google.rpc.RetryInfo',
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A non-retryable error indicating a hard quota limit has been reached (e.g., daily limit).
|
* A non-retryable error indicating a hard quota limit has been reached (e.g., daily limit).
|
||||||
*/
|
*/
|
||||||
@@ -136,8 +144,7 @@ function classifyValidationRequiredError(
|
|||||||
googleApiError: GoogleApiError,
|
googleApiError: GoogleApiError,
|
||||||
): ValidationRequiredError | null {
|
): ValidationRequiredError | null {
|
||||||
const errorInfo = googleApiError.details.find(
|
const errorInfo = googleApiError.details.find(
|
||||||
(d): d is ErrorInfo =>
|
(d): d is ErrorInfo => d['@type'] === GoogleApiType.ERROR_INFO,
|
||||||
d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!errorInfo) {
|
if (!errorInfo) {
|
||||||
@@ -154,7 +161,7 @@ function classifyValidationRequiredError(
|
|||||||
|
|
||||||
// Try to extract validation info from Help detail first
|
// Try to extract validation info from Help detail first
|
||||||
const helpDetail = googleApiError.details.find(
|
const helpDetail = googleApiError.details.find(
|
||||||
(d): d is Help => d['@type'] === 'type.googleapis.com/google.rpc.Help',
|
(d): d is Help => d['@type'] === GoogleApiType.HELP,
|
||||||
);
|
);
|
||||||
|
|
||||||
let validationLink: string | undefined;
|
let validationLink: string | undefined;
|
||||||
@@ -198,12 +205,13 @@ function classifyValidationRequiredError(
|
|||||||
* - 404 errors are classified as `ModelNotFoundError`.
|
* - 404 errors are classified as `ModelNotFoundError`.
|
||||||
* - 403 errors with `VALIDATION_REQUIRED` from cloudcode-pa domains are classified
|
* - 403 errors with `VALIDATION_REQUIRED` from cloudcode-pa domains are classified
|
||||||
* as `ValidationRequiredError`.
|
* as `ValidationRequiredError`.
|
||||||
* - 429 errors are classified as either `TerminalQuotaError` or `RetryableQuotaError`:
|
* - 429 or 499 errors are classified as either `TerminalQuotaError` or `RetryableQuotaError`:
|
||||||
* - CloudCode API: `RATE_LIMIT_EXCEEDED` → `RetryableQuotaError`, `QUOTA_EXHAUSTED` → `TerminalQuotaError`.
|
* - CloudCode API: `RATE_LIMIT_EXCEEDED` → `RetryableQuotaError`, `QUOTA_EXHAUSTED` → `TerminalQuotaError`.
|
||||||
* - If the error indicates a daily limit (in QuotaFailure), it's a `TerminalQuotaError`.
|
* - If the error indicates a daily limit (in QuotaFailure), it's a `TerminalQuotaError`.
|
||||||
* - If the error has a retry delay, it's a `RetryableQuotaError`.
|
* - If the error has a retry delay, it's a `RetryableQuotaError`.
|
||||||
* - If the error indicates a per-minute limit, it's a `RetryableQuotaError`.
|
* - If the error indicates a per-minute limit, it's a `RetryableQuotaError`.
|
||||||
* - If the error message contains the phrase "Please retry in X[s|ms]", it's a `RetryableQuotaError`.
|
* - If the error message contains the phrase "Please retry in X[s|ms]", it's a `RetryableQuotaError`.
|
||||||
|
* - 503 errors are classified as `RetryableQuotaError`.
|
||||||
*
|
*
|
||||||
* @param error The error to classify.
|
* @param error The error to classify.
|
||||||
* @returns A classified error or the original `unknown` error.
|
* @returns A classified error or the original `unknown` error.
|
||||||
@@ -227,24 +235,11 @@ export function classifyGoogleError(error: unknown): unknown {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for 503 Service Unavailable errors
|
|
||||||
if (status === 503) {
|
|
||||||
const errorMessage =
|
|
||||||
googleApiError?.message ||
|
|
||||||
(error instanceof Error ? error.message : String(error));
|
|
||||||
return new RetryableQuotaError(
|
|
||||||
errorMessage,
|
|
||||||
googleApiError ?? {
|
|
||||||
code: 503,
|
|
||||||
message: errorMessage,
|
|
||||||
details: [],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!googleApiError ||
|
!googleApiError ||
|
||||||
(googleApiError.code !== 429 && googleApiError.code !== 499) ||
|
(googleApiError.code !== 429 &&
|
||||||
|
googleApiError.code !== 499 &&
|
||||||
|
googleApiError.code !== 503) ||
|
||||||
googleApiError.details.length === 0
|
googleApiError.details.length === 0
|
||||||
) {
|
) {
|
||||||
// Fallback: try to parse the error message for a retry delay
|
// Fallback: try to parse the error message for a retry delay
|
||||||
@@ -265,9 +260,9 @@ export function classifyGoogleError(error: unknown): unknown {
|
|||||||
}
|
}
|
||||||
return new RetryableQuotaError(errorMessage, cause, retryDelaySeconds);
|
return new RetryableQuotaError(errorMessage, cause, retryDelaySeconds);
|
||||||
}
|
}
|
||||||
} else if (status === 429 || status === 499) {
|
} else if (status === 429 || status === 499 || status === 503) {
|
||||||
// Fallback: If it is a 429 or 499 but doesn't have a specific "retry in" message,
|
// Fallback: If it is a 429, 499, or 503 but doesn't have a specific "retry in" message,
|
||||||
// assume it is a temporary rate limit and retry after 5 sec (same as DEFAULT_RETRY_OPTIONS).
|
// assume it is a temporary rate limit and retry.
|
||||||
return new RetryableQuotaError(
|
return new RetryableQuotaError(
|
||||||
errorMessage,
|
errorMessage,
|
||||||
googleApiError ?? {
|
googleApiError ?? {
|
||||||
@@ -282,18 +277,15 @@ export function classifyGoogleError(error: unknown): unknown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const quotaFailure = googleApiError.details.find(
|
const quotaFailure = googleApiError.details.find(
|
||||||
(d): d is QuotaFailure =>
|
(d): d is QuotaFailure => d['@type'] === GoogleApiType.QUOTA_FAILURE,
|
||||||
d['@type'] === 'type.googleapis.com/google.rpc.QuotaFailure',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const errorInfo = googleApiError.details.find(
|
const errorInfo = googleApiError.details.find(
|
||||||
(d): d is ErrorInfo =>
|
(d): d is ErrorInfo => d['@type'] === GoogleApiType.ERROR_INFO,
|
||||||
d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const retryInfo = googleApiError.details.find(
|
const retryInfo = googleApiError.details.find(
|
||||||
(d): d is RetryInfo =>
|
(d): d is RetryInfo => d['@type'] === GoogleApiType.RETRY_INFO,
|
||||||
d['@type'] === 'type.googleapis.com/google.rpc.RetryInfo',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 1. Check for long-term limits in QuotaFailure or ErrorInfo
|
// 1. Check for long-term limits in QuotaFailure or ErrorInfo
|
||||||
@@ -321,7 +313,7 @@ export function classifyGoogleError(error: unknown): unknown {
|
|||||||
// INSUFFICIENT_G1_CREDITS_BALANCE is always terminal, regardless of domain
|
// INSUFFICIENT_G1_CREDITS_BALANCE is always terminal, regardless of domain
|
||||||
if (errorInfo.reason === 'INSUFFICIENT_G1_CREDITS_BALANCE') {
|
if (errorInfo.reason === 'INSUFFICIENT_G1_CREDITS_BALANCE') {
|
||||||
return new TerminalQuotaError(
|
return new TerminalQuotaError(
|
||||||
`${googleApiError.message}`,
|
googleApiError.message,
|
||||||
googleApiError,
|
googleApiError,
|
||||||
delaySeconds,
|
delaySeconds,
|
||||||
errorInfo.reason,
|
errorInfo.reason,
|
||||||
@@ -335,21 +327,21 @@ export function classifyGoogleError(error: unknown): unknown {
|
|||||||
const effectiveDelay = delaySeconds ?? 10;
|
const effectiveDelay = delaySeconds ?? 10;
|
||||||
if (effectiveDelay > MAX_RETRYABLE_DELAY_SECONDS) {
|
if (effectiveDelay > MAX_RETRYABLE_DELAY_SECONDS) {
|
||||||
return new TerminalQuotaError(
|
return new TerminalQuotaError(
|
||||||
`${googleApiError.message}`,
|
googleApiError.message,
|
||||||
googleApiError,
|
googleApiError,
|
||||||
effectiveDelay,
|
effectiveDelay,
|
||||||
errorInfo.reason,
|
errorInfo.reason,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return new RetryableQuotaError(
|
return new RetryableQuotaError(
|
||||||
`${googleApiError.message}`,
|
googleApiError.message,
|
||||||
googleApiError,
|
googleApiError,
|
||||||
effectiveDelay,
|
effectiveDelay,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (errorInfo.reason === 'QUOTA_EXHAUSTED') {
|
if (errorInfo.reason === 'QUOTA_EXHAUSTED') {
|
||||||
return new TerminalQuotaError(
|
return new TerminalQuotaError(
|
||||||
`${googleApiError.message}`,
|
googleApiError.message,
|
||||||
googleApiError,
|
googleApiError,
|
||||||
delaySeconds,
|
delaySeconds,
|
||||||
errorInfo.reason,
|
errorInfo.reason,
|
||||||
@@ -400,19 +392,10 @@ export function classifyGoogleError(error: unknown): unknown {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we reached this point and the status is still 429 or 499, we return retryable.
|
// If we reached this point, the status is 429, 499, or 503 and we have details,
|
||||||
if (status === 429 || status === 499) {
|
// but no specific violation was matched. We return a generic retryable error.
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
googleApiError?.message ||
|
googleApiError.message ||
|
||||||
(error instanceof Error ? error.message : String(error));
|
(error instanceof Error ? error.message : String(error));
|
||||||
return new RetryableQuotaError(
|
return new RetryableQuotaError(errorMessage, googleApiError);
|
||||||
errorMessage,
|
|
||||||
googleApiError ?? {
|
|
||||||
code: status,
|
|
||||||
message: errorMessage,
|
|
||||||
details: [],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return error; // Fallback to original error if no specific classification fits.
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user