mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-22 02:54:31 -07:00
Add interactive ValidationDialog for handling 403 VALIDATION_REQUIRED errors. (#16231)
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
classifyGoogleError,
|
||||
RetryableQuotaError,
|
||||
TerminalQuotaError,
|
||||
ValidationRequiredError,
|
||||
} from './googleQuotaErrors.js';
|
||||
import * as errorParser from './googleErrors.js';
|
||||
import type { GoogleApiError } from './googleErrors.js';
|
||||
@@ -449,4 +450,190 @@ describe('classifyGoogleError', () => {
|
||||
expect(result.retryDelayMs).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return ValidationRequiredError for 403 with VALIDATION_REQUIRED from cloudcode-pa domain', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 403,
|
||||
message: 'Validation required to continue.',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'VALIDATION_REQUIRED',
|
||||
domain: 'cloudcode-pa.googleapis.com',
|
||||
metadata: {
|
||||
validation_link: 'https://fallback.example.com/validate',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.Help',
|
||||
links: [
|
||||
{
|
||||
description: 'Complete validation to continue',
|
||||
url: 'https://example.com/validate',
|
||||
},
|
||||
{
|
||||
description: 'Learn more',
|
||||
url: 'https://support.google.com/accounts?p=al_alert',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
|
||||
const result = classifyGoogleError(new Error());
|
||||
expect(result).toBeInstanceOf(ValidationRequiredError);
|
||||
expect((result as ValidationRequiredError).validationLink).toBe(
|
||||
'https://example.com/validate',
|
||||
);
|
||||
expect((result as ValidationRequiredError).validationDescription).toBe(
|
||||
'Complete validation to continue',
|
||||
);
|
||||
expect((result as ValidationRequiredError).learnMoreUrl).toBe(
|
||||
'https://support.google.com/accounts?p=al_alert',
|
||||
);
|
||||
expect((result as ValidationRequiredError).cause).toBe(apiError);
|
||||
});
|
||||
|
||||
it('should correctly parse Learn more URL when first link description contains "Learn more" text', () => {
|
||||
// This tests the real API response format where the description of the first
|
||||
// link contains "Learn more:" text, but we should use the second link's URL
|
||||
const apiError: GoogleApiError = {
|
||||
code: 403,
|
||||
message: 'Validation required to continue.',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'VALIDATION_REQUIRED',
|
||||
domain: 'cloudcode-pa.googleapis.com',
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.Help',
|
||||
links: [
|
||||
{
|
||||
description:
|
||||
'Further action is required to use this service. Navigate to the following URL to complete verification:\n\nhttps://accounts.sandbox.google.com/signin/continue?...\n\nLearn more:\n\nhttps://support.google.com/accounts?p=al_alert\n',
|
||||
url: 'https://accounts.sandbox.google.com/signin/continue?sarp=1&scc=1&continue=...',
|
||||
},
|
||||
{
|
||||
description: 'Learn more',
|
||||
url: 'https://support.google.com/accounts?p=al_alert',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
|
||||
const result = classifyGoogleError(new Error());
|
||||
expect(result).toBeInstanceOf(ValidationRequiredError);
|
||||
// Should get the validation link from the first link
|
||||
expect((result as ValidationRequiredError).validationLink).toBe(
|
||||
'https://accounts.sandbox.google.com/signin/continue?sarp=1&scc=1&continue=...',
|
||||
);
|
||||
// Should get the Learn more URL from the SECOND link, not the first
|
||||
expect((result as ValidationRequiredError).learnMoreUrl).toBe(
|
||||
'https://support.google.com/accounts?p=al_alert',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to ErrorInfo metadata when Help detail is not present', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 403,
|
||||
message: 'Validation required.',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'VALIDATION_REQUIRED',
|
||||
domain: 'staging-cloudcode-pa.googleapis.com',
|
||||
metadata: {
|
||||
validation_link: 'https://staging.example.com/validate',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
|
||||
const result = classifyGoogleError(new Error());
|
||||
expect(result).toBeInstanceOf(ValidationRequiredError);
|
||||
expect((result as ValidationRequiredError).validationLink).toBe(
|
||||
'https://staging.example.com/validate',
|
||||
);
|
||||
expect(
|
||||
(result as ValidationRequiredError).validationDescription,
|
||||
).toBeUndefined();
|
||||
expect((result as ValidationRequiredError).learnMoreUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return original error for 403 with different reason', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 403,
|
||||
message: 'Access denied.',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'ACCESS_DENIED',
|
||||
domain: 'cloudcode-pa.googleapis.com',
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
|
||||
const originalError = new Error();
|
||||
const result = classifyGoogleError(originalError);
|
||||
expect(result).toBe(originalError);
|
||||
expect(result).not.toBeInstanceOf(ValidationRequiredError);
|
||||
});
|
||||
|
||||
it('should find learn more link by hostname when description is different', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 403,
|
||||
message: 'Validation required.',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'VALIDATION_REQUIRED',
|
||||
domain: 'cloudcode-pa.googleapis.com',
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.Help',
|
||||
links: [
|
||||
{
|
||||
description: 'Complete validation',
|
||||
url: 'https://accounts.google.com/validate',
|
||||
},
|
||||
{
|
||||
description: 'More information', // Not exactly "Learn more"
|
||||
url: 'https://support.google.com/accounts?p=al_alert',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
|
||||
const result = classifyGoogleError(new Error());
|
||||
expect(result).toBeInstanceOf(ValidationRequiredError);
|
||||
expect((result as ValidationRequiredError).learnMoreUrl).toBe(
|
||||
'https://support.google.com/accounts?p=al_alert',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return original error for 403 from non-cloudcode domain', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 403,
|
||||
message: 'Forbidden.',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'VALIDATION_REQUIRED',
|
||||
domain: 'other.googleapis.com',
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
|
||||
const originalError = new Error();
|
||||
const result = classifyGoogleError(originalError);
|
||||
expect(result).toBe(originalError);
|
||||
expect(result).not.toBeInstanceOf(ValidationRequiredError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type {
|
||||
ErrorInfo,
|
||||
GoogleApiError,
|
||||
Help,
|
||||
QuotaFailure,
|
||||
RetryInfo,
|
||||
} from './googleErrors.js';
|
||||
@@ -51,6 +52,30 @@ export class RetryableQuotaError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -69,18 +94,94 @@ function parseDurationInSeconds(duration: string): number | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes a caught error and classifies it as a specific quota-related error if applicable.
|
||||
* 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.
|
||||
*
|
||||
* It decides whether an error is a `TerminalQuotaError` or a `RetryableQuotaError` based on
|
||||
* the following logic:
|
||||
* - If the error indicates a daily limit, it's a `TerminalQuotaError`.
|
||||
* - If the error suggests a retry delay of more than 2 minutes, it's a `TerminalQuotaError`.
|
||||
* - If the error suggests a retry delay of 2 minutes or less, 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 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`:
|
||||
* - If the error indicates a daily limit, it's a `TerminalQuotaError`.
|
||||
* - If the error suggests a retry delay of more than 2 minutes, it's a `TerminalQuotaError`.
|
||||
* - If the error suggests a retry delay of 2 minutes or less, 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 `TerminalQuotaError`, `RetryableQuotaError`, or the original `unknown` error.
|
||||
* @returns A classified error or the original `unknown` error.
|
||||
*/
|
||||
export function classifyGoogleError(error: unknown): unknown {
|
||||
const googleApiError = parseGoogleApiError(error);
|
||||
@@ -93,6 +194,14 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!googleApiError ||
|
||||
googleApiError.code !== 429 ||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ApiError } from '@google/genai';
|
||||
import {
|
||||
TerminalQuotaError,
|
||||
RetryableQuotaError,
|
||||
ValidationRequiredError,
|
||||
classifyGoogleError,
|
||||
} from './googleQuotaErrors.js';
|
||||
import { delay, createAbortError } from './delay.js';
|
||||
@@ -28,6 +29,9 @@ export interface RetryOptions {
|
||||
authType?: string,
|
||||
error?: unknown,
|
||||
) => Promise<string | boolean | null>;
|
||||
onValidationRequired?: (
|
||||
error: ValidationRequiredError,
|
||||
) => Promise<'verify' | 'change_auth' | 'cancel'>;
|
||||
authType?: string;
|
||||
retryFetchErrors?: boolean;
|
||||
signal?: AbortSignal;
|
||||
@@ -144,6 +148,7 @@ export async function retryWithBackoff<T>(
|
||||
initialDelayMs,
|
||||
maxDelayMs,
|
||||
onPersistent429,
|
||||
onValidationRequired,
|
||||
authType,
|
||||
shouldRetryOnError,
|
||||
shouldRetryOnContent,
|
||||
@@ -220,6 +225,26 @@ export async function retryWithBackoff<T>(
|
||||
throw classifiedError; // Throw if no fallback or fallback failed.
|
||||
}
|
||||
|
||||
// Handle ValidationRequiredError - user needs to verify before proceeding
|
||||
if (classifiedError instanceof ValidationRequiredError) {
|
||||
if (onValidationRequired) {
|
||||
try {
|
||||
const intent = await onValidationRequired(classifiedError);
|
||||
if (intent === 'verify') {
|
||||
// User verified, retry the request
|
||||
attempt = 0;
|
||||
currentDelay = initialDelayMs;
|
||||
continue;
|
||||
}
|
||||
// 'change_auth' or 'cancel' - mark as handled and throw
|
||||
classifiedError.userHandled = true;
|
||||
} catch (validationError) {
|
||||
debugLogger.warn('Validation handler failed:', validationError);
|
||||
}
|
||||
}
|
||||
throw classifiedError;
|
||||
}
|
||||
|
||||
const is500 =
|
||||
errorCode !== undefined && errorCode >= 500 && errorCode < 600;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user