Add interactive ValidationDialog for handling 403 VALIDATION_REQUIRED errors. (#16231)

This commit is contained in:
Gaurav
2026-01-20 16:23:01 -08:00
committed by GitHub
parent aceb06a587
commit 3b626e7c61
18 changed files with 1060 additions and 12 deletions
@@ -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);
});
});
+118 -9
View File
@@ -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 ||
+25
View File
@@ -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;