mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 20:14:44 -07:00
Add interactive ValidationDialog for handling 403 VALIDATION_REQUIRED errors. (#16231)
This commit is contained in:
@@ -166,6 +166,7 @@ const mockUIActions: UIActions = {
|
||||
handleFinalSubmit: vi.fn(),
|
||||
handleClearScreen: vi.fn(),
|
||||
handleProQuotaChoice: vi.fn(),
|
||||
handleValidationChoice: vi.fn(),
|
||||
setQueueErrorMessage: vi.fn(),
|
||||
popAllMessages: vi.fn(),
|
||||
handleApiKeySubmit: vi.fn(),
|
||||
|
||||
@@ -495,7 +495,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
}
|
||||
}, [authState, authContext, setAuthState]);
|
||||
|
||||
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
|
||||
const {
|
||||
proQuotaRequest,
|
||||
handleProQuotaChoice,
|
||||
validationRequest,
|
||||
handleValidationChoice,
|
||||
} = useQuotaAndFallback({
|
||||
config,
|
||||
historyManager,
|
||||
userTier,
|
||||
@@ -1471,6 +1476,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
showPrivacyNotice ||
|
||||
showIdeRestartPrompt ||
|
||||
!!proQuotaRequest ||
|
||||
!!validationRequest ||
|
||||
isSessionBrowserOpen ||
|
||||
isAuthDialogOpen ||
|
||||
authState === AuthState.AwaitingApiKeyInput;
|
||||
@@ -1588,6 +1594,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
currentModel,
|
||||
userTier,
|
||||
proQuotaRequest,
|
||||
validationRequest,
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
@@ -1678,6 +1685,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
showAutoAcceptIndicator,
|
||||
userTier,
|
||||
proQuotaRequest,
|
||||
validationRequest,
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
@@ -1747,6 +1755,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleFinalSubmit,
|
||||
handleClearScreen,
|
||||
handleProQuotaChoice,
|
||||
handleValidationChoice,
|
||||
openSessionBrowser,
|
||||
closeSessionBrowser,
|
||||
handleResumeSession,
|
||||
@@ -1787,6 +1796,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleFinalSubmit,
|
||||
handleClearScreen,
|
||||
handleProQuotaChoice,
|
||||
handleValidationChoice,
|
||||
openSessionBrowser,
|
||||
closeSessionBrowser,
|
||||
handleResumeSession,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ApiAuthDialog } from '../auth/ApiAuthDialog.js';
|
||||
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
||||
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
|
||||
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
||||
import { ValidationDialog } from './ValidationDialog.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
|
||||
import { SessionBrowser } from './SessionBrowser.js';
|
||||
@@ -68,6 +69,16 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.validationRequest) {
|
||||
return (
|
||||
<ValidationDialog
|
||||
validationLink={uiState.validationRequest.validationLink}
|
||||
validationDescription={uiState.validationRequest.validationDescription}
|
||||
learnMoreUrl={uiState.validationRequest.learnMoreUrl}
|
||||
onChoice={uiActions.handleValidationChoice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.shouldShowIdePrompt) {
|
||||
return (
|
||||
<IdeIntegrationNudge
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { act } from 'react';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { ValidationDialog } from './ValidationDialog.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
|
||||
// Mock the child components and utilities
|
||||
vi.mock('./shared/RadioButtonSelect.js', () => ({
|
||||
RadioButtonSelect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./CliSpinner.js', () => ({
|
||||
CliSpinner: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
const mockOpenBrowserSecurely = vi.fn();
|
||||
const mockShouldLaunchBrowser = vi.fn();
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
openBrowserSecurely: (...args: unknown[]) =>
|
||||
mockOpenBrowserSecurely(...args),
|
||||
shouldLaunchBrowser: () => mockShouldLaunchBrowser(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('ValidationDialog', () => {
|
||||
const mockOnChoice = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockShouldLaunchBrowser.mockReturnValue(true);
|
||||
mockOpenBrowserSecurely.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('initial render (choosing state)', () => {
|
||||
it('should render the main message and two options', () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
<ValidationDialog onChoice={mockOnChoice} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'Further action is required to use this service.',
|
||||
);
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Verify your account',
|
||||
value: 'verify',
|
||||
key: 'verify',
|
||||
},
|
||||
{
|
||||
label: 'Change authentication',
|
||||
value: 'change_auth',
|
||||
key: 'change_auth',
|
||||
},
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render learn more URL when provided', () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
<ValidationDialog
|
||||
learnMoreUrl="https://example.com/help"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Learn more:');
|
||||
expect(lastFrame()).toContain('https://example.com/help');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onChoice handling', () => {
|
||||
it('should call onChoice with change_auth when that option is selected', () => {
|
||||
const { unmount } = render(<ValidationDialog onChoice={mockOnChoice} />);
|
||||
|
||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
||||
act(() => {
|
||||
onSelect('change_auth');
|
||||
});
|
||||
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('change_auth');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call onChoice with verify when no validation link is provided', () => {
|
||||
const { unmount } = render(<ValidationDialog onChoice={mockOnChoice} />);
|
||||
|
||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
||||
act(() => {
|
||||
onSelect('verify');
|
||||
});
|
||||
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('verify');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should open browser and transition to waiting state when verify is selected with a link', async () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
<ValidationDialog
|
||||
validationLink="https://accounts.google.com/verify"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
|
||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
||||
await act(async () => {
|
||||
await onSelect('verify');
|
||||
});
|
||||
|
||||
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(
|
||||
'https://accounts.google.com/verify',
|
||||
);
|
||||
expect(lastFrame()).toContain('Waiting for verification...');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('headless mode', () => {
|
||||
it('should show URL in message when browser cannot be launched', async () => {
|
||||
mockShouldLaunchBrowser.mockReturnValue(false);
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<ValidationDialog
|
||||
validationLink="https://accounts.google.com/verify"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
|
||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
||||
await act(async () => {
|
||||
await onSelect('verify');
|
||||
});
|
||||
|
||||
expect(mockOpenBrowserSecurely).not.toHaveBeenCalled();
|
||||
expect(lastFrame()).toContain('Please open this URL in a browser:');
|
||||
expect(lastFrame()).toContain('https://accounts.google.com/verify');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error state', () => {
|
||||
it('should show error and options when browser fails to open', async () => {
|
||||
mockOpenBrowserSecurely.mockRejectedValue(new Error('Browser not found'));
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<ValidationDialog
|
||||
validationLink="https://accounts.google.com/verify"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
|
||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
||||
await act(async () => {
|
||||
await onSelect('verify');
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('Browser not found');
|
||||
// RadioButtonSelect should be rendered again with options in error state
|
||||
expect((RadioButtonSelect as Mock).mock.calls.length).toBeGreaterThan(1);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { CliSpinner } from './CliSpinner.js';
|
||||
import {
|
||||
openBrowserSecurely,
|
||||
shouldLaunchBrowser,
|
||||
type ValidationIntent,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
|
||||
interface ValidationDialogProps {
|
||||
validationLink?: string;
|
||||
validationDescription?: string;
|
||||
learnMoreUrl?: string;
|
||||
onChoice: (choice: ValidationIntent) => void;
|
||||
}
|
||||
|
||||
type DialogState = 'choosing' | 'waiting' | 'complete' | 'error';
|
||||
|
||||
export function ValidationDialog({
|
||||
validationLink,
|
||||
learnMoreUrl,
|
||||
onChoice,
|
||||
}: ValidationDialogProps): React.JSX.Element {
|
||||
const [state, setState] = useState<DialogState>('choosing');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: 'Verify your account',
|
||||
value: 'verify' as const,
|
||||
key: 'verify',
|
||||
},
|
||||
{
|
||||
label: 'Change authentication',
|
||||
value: 'change_auth' as const,
|
||||
key: 'change_auth',
|
||||
},
|
||||
];
|
||||
|
||||
// Handle keypresses during 'waiting' state (ESC to cancel, Enter to confirm completion)
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key) || keyMatchers[Command.QUIT](key)) {
|
||||
onChoice('cancel');
|
||||
} else if (keyMatchers[Command.RETURN](key)) {
|
||||
// User confirmed verification is complete - transition to 'complete' state
|
||||
setState('complete');
|
||||
}
|
||||
},
|
||||
{ isActive: state === 'waiting' },
|
||||
);
|
||||
|
||||
// When state becomes 'complete', show success message briefly then proceed
|
||||
useEffect(() => {
|
||||
if (state === 'complete') {
|
||||
const timer = setTimeout(() => {
|
||||
onChoice('verify');
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [state, onChoice]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (choice: ValidationIntent) => {
|
||||
if (choice === 'verify') {
|
||||
if (validationLink) {
|
||||
// Check if we're in an environment where we can launch a browser
|
||||
if (!shouldLaunchBrowser()) {
|
||||
// In headless mode, show the link and wait for user to manually verify
|
||||
setErrorMessage(
|
||||
`Please open this URL in a browser: ${validationLink}`,
|
||||
);
|
||||
setState('waiting');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await openBrowserSecurely(validationLink);
|
||||
setState('waiting');
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : 'Failed to open browser',
|
||||
);
|
||||
setState('error');
|
||||
}
|
||||
} else {
|
||||
// No validation link, just retry
|
||||
onChoice('verify');
|
||||
}
|
||||
} else {
|
||||
// 'change_auth' or 'cancel'
|
||||
onChoice(choice);
|
||||
}
|
||||
},
|
||||
[validationLink, onChoice],
|
||||
);
|
||||
|
||||
if (state === 'error') {
|
||||
return (
|
||||
<Box borderStyle="round" flexDirection="column" padding={1}>
|
||||
<Text color={theme.status.error}>
|
||||
{errorMessage ||
|
||||
'Failed to open verification link. Please try again or change authentication.'}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
items={items}
|
||||
onSelect={(choice) => void handleSelect(choice as ValidationIntent)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'waiting') {
|
||||
return (
|
||||
<Box borderStyle="round" flexDirection="column" padding={1}>
|
||||
<Box>
|
||||
<CliSpinner />
|
||||
<Text>
|
||||
{' '}
|
||||
Waiting for verification... (Press ESC or CTRL+C to cancel)
|
||||
</Text>
|
||||
</Box>
|
||||
{errorMessage && (
|
||||
<Box marginTop={1}>
|
||||
<Text>{errorMessage}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Press Enter when verification is complete.</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'complete') {
|
||||
return (
|
||||
<Box borderStyle="round" flexDirection="column" padding={1}>
|
||||
<Text color={theme.status.success}>Verification complete</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" flexDirection="column" padding={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text>Further action is required to use this service.</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} marginBottom={1}>
|
||||
<RadioButtonSelect
|
||||
items={items}
|
||||
onSelect={(choice) => void handleSelect(choice as ValidationIntent)}
|
||||
/>
|
||||
</Box>
|
||||
{learnMoreUrl && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
Learn more: <Text color={theme.text.accent}>{learnMoreUrl}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,7 @@ export interface UIActions {
|
||||
handleProQuotaChoice: (
|
||||
choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade',
|
||||
) => void;
|
||||
handleValidationChoice: (choice: 'verify' | 'change_auth' | 'cancel') => void;
|
||||
openSessionBrowser: () => void;
|
||||
closeSessionBrowser: () => void;
|
||||
handleResumeSession: (session: SessionInfo) => Promise<void>;
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
UserTierId,
|
||||
IdeInfo,
|
||||
FallbackIntent,
|
||||
ValidationIntent,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { DOMElement } from 'ink';
|
||||
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
||||
@@ -38,6 +39,13 @@ export interface ProQuotaDialogRequest {
|
||||
resolve: (intent: FallbackIntent) => void;
|
||||
}
|
||||
|
||||
export interface ValidationDialogRequest {
|
||||
validationLink?: string;
|
||||
validationDescription?: string;
|
||||
learnMoreUrl?: string;
|
||||
resolve: (intent: ValidationIntent) => void;
|
||||
}
|
||||
|
||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
||||
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
|
||||
@@ -102,6 +110,7 @@ export interface UIState {
|
||||
// Quota-related state
|
||||
userTier: UserTierId | undefined;
|
||||
proQuotaRequest: ProQuotaDialogRequest | null;
|
||||
validationRequest: ValidationDialogRequest | null;
|
||||
currentModel: string;
|
||||
contextFileNames: string[];
|
||||
errorCount: number;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
processRestorableToolCalls,
|
||||
recordToolCallInteractions,
|
||||
ToolErrorType,
|
||||
ValidationRequiredError,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
MCPDiscoveryState,
|
||||
@@ -1100,6 +1101,12 @@ export const useGeminiStream = (
|
||||
spanMetadata.error = error;
|
||||
if (error instanceof UnauthorizedError) {
|
||||
onAuthError('Session expired or is unauthorized.');
|
||||
} else if (
|
||||
// Suppress ValidationRequiredError if it was marked as handled (e.g. user clicked change_auth or cancelled)
|
||||
error instanceof ValidationRequiredError &&
|
||||
error.userHandled
|
||||
) {
|
||||
// Error was handled by validation dialog, don't display again
|
||||
} else if (!isNodeError(error) || error.name !== 'AbortError') {
|
||||
addItem(
|
||||
{
|
||||
|
||||
@@ -498,4 +498,186 @@ To disable gemini-3-pro-preview, disable "Preview features" in /settings.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation Handler', () => {
|
||||
let setValidationHandlerSpy: SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
setValidationHandlerSpy = vi.spyOn(mockConfig, 'setValidationHandler');
|
||||
});
|
||||
|
||||
it('should register a validation handler on initialization', () => {
|
||||
renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(setValidationHandlerSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setValidationHandlerSpy.mock.calls[0][0]).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should set a validation request when handler is called', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setValidationHandlerSpy.mock.calls[0][0] as (
|
||||
validationLink?: string,
|
||||
validationDescription?: string,
|
||||
learnMoreUrl?: string,
|
||||
) => Promise<'verify' | 'change_auth' | 'cancel'>;
|
||||
|
||||
let promise: Promise<'verify' | 'change_auth' | 'cancel'>;
|
||||
act(() => {
|
||||
promise = handler(
|
||||
'https://example.com/verify',
|
||||
'Please verify',
|
||||
'https://example.com/help',
|
||||
);
|
||||
});
|
||||
|
||||
const request = result.current.validationRequest;
|
||||
expect(request).not.toBeNull();
|
||||
expect(request?.validationLink).toBe('https://example.com/verify');
|
||||
expect(request?.validationDescription).toBe('Please verify');
|
||||
expect(request?.learnMoreUrl).toBe('https://example.com/help');
|
||||
|
||||
// Simulate user choosing verify
|
||||
act(() => {
|
||||
result.current.handleValidationChoice('verify');
|
||||
});
|
||||
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('verify');
|
||||
expect(result.current.validationRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle race conditions by returning cancel for subsequent requests', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setValidationHandlerSpy.mock.calls[0][0] as (
|
||||
validationLink?: string,
|
||||
) => Promise<'verify' | 'change_auth' | 'cancel'>;
|
||||
|
||||
let promise1: Promise<'verify' | 'change_auth' | 'cancel'>;
|
||||
act(() => {
|
||||
promise1 = handler('https://example.com/verify1');
|
||||
});
|
||||
|
||||
const firstRequest = result.current.validationRequest;
|
||||
expect(firstRequest).not.toBeNull();
|
||||
|
||||
let result2: 'verify' | 'change_auth' | 'cancel';
|
||||
await act(async () => {
|
||||
result2 = await handler('https://example.com/verify2');
|
||||
});
|
||||
|
||||
// The lock should have stopped the second request
|
||||
expect(result2!).toBe('cancel');
|
||||
expect(result.current.validationRequest).toBe(firstRequest);
|
||||
|
||||
// Complete the first request
|
||||
act(() => {
|
||||
result.current.handleValidationChoice('verify');
|
||||
});
|
||||
|
||||
const intent1 = await promise1!;
|
||||
expect(intent1).toBe('verify');
|
||||
expect(result.current.validationRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('should add info message when change_auth is chosen', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setValidationHandlerSpy.mock.calls[0][0] as (
|
||||
validationLink?: string,
|
||||
) => Promise<'verify' | 'change_auth' | 'cancel'>;
|
||||
|
||||
let promise: Promise<'verify' | 'change_auth' | 'cancel'>;
|
||||
act(() => {
|
||||
promise = handler('https://example.com/verify');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleValidationChoice('change_auth');
|
||||
});
|
||||
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('change_auth');
|
||||
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1);
|
||||
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[0][0];
|
||||
expect(lastCall.type).toBe(MessageType.INFO);
|
||||
expect(lastCall.text).toBe('Use /auth to change authentication method.');
|
||||
});
|
||||
|
||||
it('should not add info message when cancel is chosen', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setValidationHandlerSpy.mock.calls[0][0] as (
|
||||
validationLink?: string,
|
||||
) => Promise<'verify' | 'change_auth' | 'cancel'>;
|
||||
|
||||
let promise: Promise<'verify' | 'change_auth' | 'cancel'>;
|
||||
act(() => {
|
||||
promise = handler('https://example.com/verify');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleValidationChoice('cancel');
|
||||
});
|
||||
|
||||
const intent = await promise!;
|
||||
expect(intent).toBe('cancel');
|
||||
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing if handleValidationChoice is called without pending request', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleValidationChoice('verify');
|
||||
});
|
||||
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type Config,
|
||||
type FallbackModelHandler,
|
||||
type FallbackIntent,
|
||||
type ValidationHandler,
|
||||
type ValidationIntent,
|
||||
TerminalQuotaError,
|
||||
ModelNotFoundError,
|
||||
type UserTierId,
|
||||
@@ -19,7 +21,10 @@ import {
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { type UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { type ProQuotaDialogRequest } from '../contexts/UIStateContext.js';
|
||||
import {
|
||||
type ProQuotaDialogRequest,
|
||||
type ValidationDialogRequest,
|
||||
} from '../contexts/UIStateContext.js';
|
||||
|
||||
interface UseQuotaAndFallbackArgs {
|
||||
config: Config;
|
||||
@@ -36,7 +41,10 @@ export function useQuotaAndFallback({
|
||||
}: UseQuotaAndFallbackArgs) {
|
||||
const [proQuotaRequest, setProQuotaRequest] =
|
||||
useState<ProQuotaDialogRequest | null>(null);
|
||||
const [validationRequest, setValidationRequest] =
|
||||
useState<ValidationDialogRequest | null>(null);
|
||||
const isDialogPending = useRef(false);
|
||||
const isValidationPending = useRef(false);
|
||||
|
||||
// Set up Flash fallback handler
|
||||
useEffect(() => {
|
||||
@@ -120,6 +128,36 @@ export function useQuotaAndFallback({
|
||||
config.setFallbackModelHandler(fallbackHandler);
|
||||
}, [config, historyManager, userTier, setModelSwitchedFromQuotaError]);
|
||||
|
||||
// Set up validation handler for 403 VALIDATION_REQUIRED errors
|
||||
useEffect(() => {
|
||||
const validationHandler: ValidationHandler = async (
|
||||
validationLink,
|
||||
validationDescription,
|
||||
learnMoreUrl,
|
||||
): Promise<ValidationIntent> => {
|
||||
if (isValidationPending.current) {
|
||||
return 'cancel'; // A validation dialog is already active
|
||||
}
|
||||
isValidationPending.current = true;
|
||||
|
||||
const intent: ValidationIntent = await new Promise<ValidationIntent>(
|
||||
(resolve) => {
|
||||
// Call setValidationRequest directly - same pattern as proQuotaRequest
|
||||
setValidationRequest({
|
||||
validationLink,
|
||||
validationDescription,
|
||||
learnMoreUrl,
|
||||
resolve,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return intent;
|
||||
};
|
||||
|
||||
config.setValidationHandler(validationHandler);
|
||||
}, [config]);
|
||||
|
||||
const handleProQuotaChoice = useCallback(
|
||||
(choice: FallbackIntent) => {
|
||||
if (!proQuotaRequest) return;
|
||||
@@ -148,9 +186,35 @@ export function useQuotaAndFallback({
|
||||
[proQuotaRequest, historyManager, config, setModelSwitchedFromQuotaError],
|
||||
);
|
||||
|
||||
const handleValidationChoice = useCallback(
|
||||
(choice: ValidationIntent) => {
|
||||
// Guard against double-execution (e.g. rapid clicks) and stale requests
|
||||
if (!isValidationPending.current || !validationRequest) return;
|
||||
|
||||
// Immediately clear the flag to prevent any subsequent calls from passing the guard
|
||||
isValidationPending.current = false;
|
||||
|
||||
validationRequest.resolve(choice);
|
||||
setValidationRequest(null);
|
||||
|
||||
if (choice === 'change_auth') {
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Use /auth to change authentication method.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
},
|
||||
[validationRequest, historyManager],
|
||||
);
|
||||
|
||||
return {
|
||||
proQuotaRequest,
|
||||
handleProQuotaChoice,
|
||||
validationRequest,
|
||||
handleValidationChoice,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,10 @@ import {
|
||||
ApprovalModeSwitchEvent,
|
||||
ApprovalModeDurationEvent,
|
||||
} from '../telemetry/types.js';
|
||||
import type { FallbackModelHandler } from '../fallback/types.js';
|
||||
import type {
|
||||
FallbackModelHandler,
|
||||
ValidationHandler,
|
||||
} from '../fallback/types.js';
|
||||
import { ModelAvailabilityService } from '../availability/modelAvailabilityService.js';
|
||||
import { ModelRouterService } from '../routing/modelRouterService.js';
|
||||
import { OutputFormat } from '../output/types.js';
|
||||
@@ -477,6 +480,7 @@ export class Config {
|
||||
private readonly _enabledExtensions: string[];
|
||||
private readonly enableExtensionReloading: boolean;
|
||||
fallbackModelHandler?: FallbackModelHandler;
|
||||
validationHandler?: ValidationHandler;
|
||||
private quotaErrorOccurred: boolean = false;
|
||||
private readonly summarizeToolOutput:
|
||||
| Record<string, SummarizeToolOutputSettings>
|
||||
@@ -1066,6 +1070,14 @@ export class Config {
|
||||
return this.fallbackModelHandler;
|
||||
}
|
||||
|
||||
setValidationHandler(handler: ValidationHandler): void {
|
||||
this.validationHandler = handler;
|
||||
}
|
||||
|
||||
getValidationHandler(): ValidationHandler | undefined {
|
||||
return this.validationHandler;
|
||||
}
|
||||
|
||||
resetTurn(): void {
|
||||
this.modelAvailabilityService.resetTurn();
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
|
||||
import { reportError } from '../utils/errorReporting.js';
|
||||
import { GeminiChat } from './geminiChat.js';
|
||||
import { retryWithBackoff } from '../utils/retry.js';
|
||||
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { tokenLimit } from './tokenLimits.js';
|
||||
import type {
|
||||
@@ -926,8 +927,29 @@ export class GeminiClient {
|
||||
// Pass the captured model to the centralized handler.
|
||||
handleFallback(this.config, currentAttemptModel, authType, error);
|
||||
|
||||
const onValidationRequiredCallback = async (
|
||||
validationError: ValidationRequiredError,
|
||||
) => {
|
||||
// Suppress validation dialog for background calls (e.g. prompt-completion)
|
||||
// to prevent the dialog from appearing on startup or during typing.
|
||||
if (modelConfigKey.model === 'prompt-completion') {
|
||||
throw validationError;
|
||||
}
|
||||
|
||||
const handler = this.config.getValidationHandler();
|
||||
if (typeof handler !== 'function') {
|
||||
throw validationError;
|
||||
}
|
||||
return handler(
|
||||
validationError.validationLink,
|
||||
validationError.validationDescription,
|
||||
validationError.learnMoreUrl,
|
||||
);
|
||||
};
|
||||
|
||||
const result = await retryWithBackoff(apiCall, {
|
||||
onPersistent429: onPersistent429Callback,
|
||||
onValidationRequired: onValidationRequiredCallback,
|
||||
authType: this.config.getContentGeneratorConfig()?.authType,
|
||||
maxAttempts: availabilityMaxAttempts,
|
||||
getAvailabilityContext,
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
import { toParts } from '../code_assist/converter.js';
|
||||
import { createUserContent, FinishReason } from '@google/genai';
|
||||
import { retryWithBackoff, isRetryableError } from '../utils/retry.js';
|
||||
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
resolveModel,
|
||||
@@ -579,8 +580,24 @@ export class GeminiChat {
|
||||
error?: unknown,
|
||||
) => handleFallback(this.config, lastModelToUse, authType, error);
|
||||
|
||||
const onValidationRequiredCallback = async (
|
||||
validationError: ValidationRequiredError,
|
||||
) => {
|
||||
const handler = this.config.getValidationHandler();
|
||||
if (typeof handler !== 'function') {
|
||||
// No handler registered, re-throw to show default error message
|
||||
throw validationError;
|
||||
}
|
||||
return handler(
|
||||
validationError.validationLink,
|
||||
validationError.validationDescription,
|
||||
validationError.learnMoreUrl,
|
||||
);
|
||||
};
|
||||
|
||||
const streamResponse = await retryWithBackoff(apiCall, {
|
||||
onPersistent429: onPersistent429Callback,
|
||||
onValidationRequired: onValidationRequiredCallback,
|
||||
authType: this.config.getContentGeneratorConfig()?.authType,
|
||||
retryFetchErrors: this.config.getRetryFetchErrors(),
|
||||
signal: abortSignal,
|
||||
|
||||
@@ -37,3 +37,21 @@ export type FallbackModelHandler = (
|
||||
fallbackModel: string,
|
||||
error?: unknown,
|
||||
) => Promise<FallbackIntent | null>;
|
||||
|
||||
/**
|
||||
* Defines the intent returned by the UI layer during a validation required scenario.
|
||||
*/
|
||||
export type ValidationIntent =
|
||||
| 'verify' // User chose to verify, wait for completion then retry.
|
||||
| 'change_auth' // User chose to change authentication method.
|
||||
| 'cancel'; // User cancelled the verification process.
|
||||
|
||||
/**
|
||||
* The interface for the handler provided by the UI layer (e.g., the CLI)
|
||||
* to interact with the user when validation is required.
|
||||
*/
|
||||
export type ValidationHandler = (
|
||||
validationLink?: string,
|
||||
validationDescription?: string,
|
||||
learnMoreUrl?: string,
|
||||
) => Promise<ValidationIntent>;
|
||||
|
||||
@@ -90,6 +90,7 @@ export * from './utils/extensionLoader.js';
|
||||
export * from './utils/package.js';
|
||||
export * from './utils/version.js';
|
||||
export * from './utils/checkpointUtils.js';
|
||||
export * from './utils/secure-browser-launcher.js';
|
||||
export * from './utils/apiConversionUtils.js';
|
||||
export * from './utils/channel.js';
|
||||
|
||||
|
||||
@@ -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