mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
372 lines
11 KiB
TypeScript
372 lines
11 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type {
|
|
ErrorInfo,
|
|
GoogleApiError,
|
|
Help,
|
|
QuotaFailure,
|
|
RetryInfo,
|
|
} from './googleErrors.js';
|
|
import { parseGoogleApiError } from './googleErrors.js';
|
|
import { getErrorStatus, ModelNotFoundError } from './httpErrors.js';
|
|
|
|
/**
|
|
* A non-retryable error indicating a hard quota limit has been reached (e.g., daily limit).
|
|
*/
|
|
export class TerminalQuotaError extends Error {
|
|
retryDelayMs?: number;
|
|
|
|
constructor(
|
|
message: string,
|
|
override readonly cause: GoogleApiError,
|
|
retryDelaySeconds?: number,
|
|
) {
|
|
super(message);
|
|
this.name = 'TerminalQuotaError';
|
|
this.retryDelayMs = retryDelaySeconds
|
|
? retryDelaySeconds * 1000
|
|
: undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A retryable error indicating a temporary quota issue (e.g., per-minute limit).
|
|
*/
|
|
export class RetryableQuotaError extends Error {
|
|
retryDelayMs?: number;
|
|
|
|
constructor(
|
|
message: string,
|
|
override readonly cause: GoogleApiError,
|
|
retryDelaySeconds?: number,
|
|
) {
|
|
super(message);
|
|
this.name = 'RetryableQuotaError';
|
|
this.retryDelayMs = retryDelaySeconds
|
|
? retryDelaySeconds * 1000
|
|
: undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* An error indicating that user validation is required to continue.
|
|
*/
|
|
export class ValidationRequiredError extends Error {
|
|
validationLink?: string;
|
|
validationDescription?: string;
|
|
learnMoreUrl?: string;
|
|
userHandled: boolean = false;
|
|
|
|
constructor(
|
|
message: string,
|
|
override readonly cause?: GoogleApiError,
|
|
validationLink?: string,
|
|
validationDescription?: string,
|
|
learnMoreUrl?: string,
|
|
) {
|
|
super(message);
|
|
this.name = 'ValidationRequiredError';
|
|
this.validationLink = validationLink;
|
|
this.validationDescription = validationDescription;
|
|
this.learnMoreUrl = learnMoreUrl;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parses a duration string (e.g., "34.074824224s", "60s", "900ms") and returns the time in seconds.
|
|
* @param duration The duration string to parse.
|
|
* @returns The duration in seconds, or null if parsing fails.
|
|
*/
|
|
function parseDurationInSeconds(duration: string): number | null {
|
|
if (duration.endsWith('ms')) {
|
|
const milliseconds = parseFloat(duration.slice(0, -2));
|
|
return isNaN(milliseconds) ? null : milliseconds / 1000;
|
|
}
|
|
if (duration.endsWith('s')) {
|
|
const seconds = parseFloat(duration.slice(0, -1));
|
|
return isNaN(seconds) ? null : seconds;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Valid Cloud Code API domains for VALIDATION_REQUIRED errors.
|
|
*/
|
|
const CLOUDCODE_DOMAINS = [
|
|
'cloudcode-pa.googleapis.com',
|
|
'staging-cloudcode-pa.googleapis.com',
|
|
'autopush-cloudcode-pa.googleapis.com',
|
|
];
|
|
|
|
/**
|
|
* Checks if a 403 error requires user validation and extracts validation details.
|
|
*
|
|
* @param googleApiError The parsed Google API error to check.
|
|
* @returns A `ValidationRequiredError` if validation is required, otherwise `null`.
|
|
*/
|
|
function classifyValidationRequiredError(
|
|
googleApiError: GoogleApiError,
|
|
): ValidationRequiredError | null {
|
|
const errorInfo = googleApiError.details.find(
|
|
(d): d is ErrorInfo =>
|
|
d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo',
|
|
);
|
|
|
|
if (!errorInfo) {
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
!CLOUDCODE_DOMAINS.includes(errorInfo.domain) ||
|
|
errorInfo.reason !== 'VALIDATION_REQUIRED'
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
// Try to extract validation info from Help detail first
|
|
const helpDetail = googleApiError.details.find(
|
|
(d): d is Help => d['@type'] === 'type.googleapis.com/google.rpc.Help',
|
|
);
|
|
|
|
let validationLink: string | undefined;
|
|
let validationDescription: string | undefined;
|
|
let learnMoreUrl: string | undefined;
|
|
|
|
if (helpDetail?.links && helpDetail.links.length > 0) {
|
|
// First link is the validation link, extract description and URL
|
|
const validationLinkInfo = helpDetail.links[0];
|
|
validationLink = validationLinkInfo.url;
|
|
validationDescription = validationLinkInfo.description;
|
|
|
|
// Look for "Learn more" link - identified by description or support.google.com hostname
|
|
const learnMoreLink = helpDetail.links.find((link) => {
|
|
if (link.description.toLowerCase().trim() === 'learn more') return true;
|
|
const parsed = URL.parse(link.url);
|
|
return parsed?.hostname === 'support.google.com';
|
|
});
|
|
if (learnMoreLink) {
|
|
learnMoreUrl = learnMoreLink.url;
|
|
}
|
|
}
|
|
|
|
// Fallback to ErrorInfo metadata if Help detail not found
|
|
if (!validationLink) {
|
|
validationLink = errorInfo.metadata?.['validation_link'];
|
|
}
|
|
|
|
return new ValidationRequiredError(
|
|
googleApiError.message,
|
|
googleApiError,
|
|
validationLink,
|
|
validationDescription,
|
|
learnMoreUrl,
|
|
);
|
|
}
|
|
/**
|
|
* Analyzes a caught error and classifies it as a specific error type if applicable.
|
|
*
|
|
* Classification logic:
|
|
* - 404 errors are classified as `ModelNotFoundError`.
|
|
* - 403 errors with `VALIDATION_REQUIRED` from cloudcode-pa domains are classified
|
|
* as `ValidationRequiredError`.
|
|
* - 429 errors are classified as either `TerminalQuotaError` or `RetryableQuotaError`:
|
|
* - 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 has a retry delay, 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`.
|
|
*
|
|
* @param error The error to classify.
|
|
* @returns A classified error or the original `unknown` error.
|
|
*/
|
|
export function classifyGoogleError(error: unknown): unknown {
|
|
const googleApiError = parseGoogleApiError(error);
|
|
const status = googleApiError?.code ?? getErrorStatus(error);
|
|
|
|
if (status === 404) {
|
|
const message =
|
|
googleApiError?.message ||
|
|
(error instanceof Error ? error.message : 'Model not found');
|
|
return new ModelNotFoundError(message, status);
|
|
}
|
|
|
|
// Check for 403 VALIDATION_REQUIRED errors from Cloud Code API
|
|
if (status === 403 && googleApiError) {
|
|
const validationError = classifyValidationRequiredError(googleApiError);
|
|
if (validationError) {
|
|
return validationError;
|
|
}
|
|
}
|
|
|
|
// 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 (
|
|
!googleApiError ||
|
|
(googleApiError.code !== 429 && googleApiError.code !== 499) ||
|
|
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]);
|
|
if (retryDelaySeconds !== null) {
|
|
return new RetryableQuotaError(
|
|
errorMessage,
|
|
googleApiError ?? {
|
|
code: status ?? 429,
|
|
message: errorMessage,
|
|
details: [],
|
|
},
|
|
retryDelaySeconds,
|
|
);
|
|
}
|
|
} else if (status === 429 || status === 499) {
|
|
// Fallback: If it is a 429 or 499 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).
|
|
return new RetryableQuotaError(
|
|
errorMessage,
|
|
googleApiError ?? {
|
|
code: status,
|
|
message: errorMessage,
|
|
details: [],
|
|
},
|
|
);
|
|
}
|
|
|
|
return error; // Not a retryable error we can handle with structured details or a parsable retry message.
|
|
}
|
|
|
|
const quotaFailure = googleApiError.details.find(
|
|
(d): d is QuotaFailure =>
|
|
d['@type'] === 'type.googleapis.com/google.rpc.QuotaFailure',
|
|
);
|
|
|
|
const errorInfo = googleApiError.details.find(
|
|
(d): d is ErrorInfo =>
|
|
d['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo',
|
|
);
|
|
|
|
const retryInfo = googleApiError.details.find(
|
|
(d): d is RetryInfo =>
|
|
d['@type'] === 'type.googleapis.com/google.rpc.RetryInfo',
|
|
);
|
|
|
|
// 1. Check for long-term limits in QuotaFailure or ErrorInfo
|
|
if (quotaFailure) {
|
|
for (const violation of quotaFailure.violations) {
|
|
const quotaId = violation.quotaId ?? '';
|
|
if (quotaId.includes('PerDay') || quotaId.includes('Daily')) {
|
|
return new TerminalQuotaError(
|
|
`You have exhausted your daily quota on this model.`,
|
|
googleApiError,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
let delaySeconds;
|
|
|
|
if (retryInfo?.retryDelay) {
|
|
const parsedDelay = parseDurationInSeconds(retryInfo.retryDelay);
|
|
if (parsedDelay) {
|
|
delaySeconds = parsedDelay;
|
|
}
|
|
}
|
|
|
|
if (errorInfo) {
|
|
// New Cloud Code API quota handling
|
|
if (errorInfo.domain) {
|
|
const validDomains = [
|
|
'cloudcode-pa.googleapis.com',
|
|
'staging-cloudcode-pa.googleapis.com',
|
|
'autopush-cloudcode-pa.googleapis.com',
|
|
];
|
|
if (validDomains.includes(errorInfo.domain)) {
|
|
if (errorInfo.reason === 'RATE_LIMIT_EXCEEDED') {
|
|
return new RetryableQuotaError(
|
|
`${googleApiError.message}`,
|
|
googleApiError,
|
|
delaySeconds ?? 10,
|
|
);
|
|
}
|
|
if (errorInfo.reason === 'QUOTA_EXHAUSTED') {
|
|
return new TerminalQuotaError(
|
|
`${googleApiError.message}`,
|
|
googleApiError,
|
|
delaySeconds,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Check for delays in RetryInfo
|
|
if (retryInfo?.retryDelay && delaySeconds) {
|
|
return new RetryableQuotaError(
|
|
`${googleApiError.message}\nSuggested retry after ${retryInfo.retryDelay}.`,
|
|
googleApiError,
|
|
delaySeconds,
|
|
);
|
|
}
|
|
|
|
// 3. Check for short-term limits in QuotaFailure or ErrorInfo
|
|
if (quotaFailure) {
|
|
for (const violation of quotaFailure.violations) {
|
|
const quotaId = violation.quotaId ?? '';
|
|
if (quotaId.includes('PerMinute')) {
|
|
return new RetryableQuotaError(
|
|
`${googleApiError.message}\nSuggested retry after 60s.`,
|
|
googleApiError,
|
|
60,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (errorInfo) {
|
|
const quotaLimit = errorInfo.metadata?.['quota_limit'] ?? '';
|
|
if (quotaLimit.includes('PerMinute')) {
|
|
return new RetryableQuotaError(
|
|
`${errorInfo.reason}\nSuggested retry after 60s.`,
|
|
googleApiError,
|
|
60,
|
|
);
|
|
}
|
|
}
|
|
|
|
// If we reached this point and the status is still 429 or 499, we return retryable.
|
|
if (status === 429 || status === 499) {
|
|
const errorMessage =
|
|
googleApiError?.message ||
|
|
(error instanceof Error ? error.message : String(error));
|
|
return new RetryableQuotaError(
|
|
errorMessage,
|
|
googleApiError ?? {
|
|
code: status,
|
|
message: errorMessage,
|
|
details: [],
|
|
},
|
|
);
|
|
}
|
|
return error; // Fallback to original error if no specific classification fits.
|
|
}
|