fix(core): honor retryDelay in RetryInfo for 503 errors (#25057)

This commit is contained in:
Yuna Seol
2026-04-09 16:16:36 -04:00
committed by GitHub
parent 2a5f798863
commit 20113ee595
2 changed files with 57 additions and 48 deletions
@@ -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,
+31 -48
View File
@@ -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.
} }