From 3b626e7c61bde810a90085f2092b02bb8dc799c7 Mon Sep 17 00:00:00 2001
From: Gaurav <39389231+gsquared94@users.noreply.github.com>
Date: Tue, 20 Jan 2026 16:23:01 -0800
Subject: [PATCH] Add interactive ValidationDialog for handling 403
VALIDATION_REQUIRED errors. (#16231)
---
packages/cli/src/test-utils/render.tsx | 1 +
packages/cli/src/ui/AppContainer.tsx | 12 +-
.../cli/src/ui/components/DialogManager.tsx | 11 +
.../ui/components/ValidationDialog.test.tsx | 195 ++++++++++++++++++
.../src/ui/components/ValidationDialog.tsx | 177 ++++++++++++++++
.../cli/src/ui/contexts/UIActionsContext.tsx | 1 +
.../cli/src/ui/contexts/UIStateContext.tsx | 9 +
packages/cli/src/ui/hooks/useGeminiStream.ts | 7 +
.../src/ui/hooks/useQuotaAndFallback.test.ts | 182 ++++++++++++++++
.../cli/src/ui/hooks/useQuotaAndFallback.ts | 66 +++++-
packages/core/src/config/config.ts | 14 +-
packages/core/src/core/client.ts | 22 ++
packages/core/src/core/geminiChat.ts | 17 ++
packages/core/src/fallback/types.ts | 18 ++
packages/core/src/index.ts | 1 +
.../core/src/utils/googleQuotaErrors.test.ts | 187 +++++++++++++++++
packages/core/src/utils/googleQuotaErrors.ts | 127 +++++++++++-
packages/core/src/utils/retry.ts | 25 +++
18 files changed, 1060 insertions(+), 12 deletions(-)
create mode 100644 packages/cli/src/ui/components/ValidationDialog.test.tsx
create mode 100644 packages/cli/src/ui/components/ValidationDialog.tsx
diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx
index 8e4ba82328..bb4cd5ca8d 100644
--- a/packages/cli/src/test-utils/render.tsx
+++ b/packages/cli/src/test-utils/render.tsx
@@ -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(),
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index d84082859b..ee87b7719d 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -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,
diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx
index f915bc7852..badbfde75a 100644
--- a/packages/cli/src/ui/components/DialogManager.tsx
+++ b/packages/cli/src/ui/components/DialogManager.tsx
@@ -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 (
+
+ );
+ }
if (uiState.shouldShowIdePrompt) {
return (
({
+ 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();
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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();
+
+ 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();
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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();
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/ValidationDialog.tsx b/packages/cli/src/ui/components/ValidationDialog.tsx
new file mode 100644
index 0000000000..b7ddf2878a
--- /dev/null
+++ b/packages/cli/src/ui/components/ValidationDialog.tsx
@@ -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('choosing');
+ const [errorMessage, setErrorMessage] = useState('');
+
+ 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 (
+
+
+ {errorMessage ||
+ 'Failed to open verification link. Please try again or change authentication.'}
+
+
+ void handleSelect(choice as ValidationIntent)}
+ />
+
+
+ );
+ }
+
+ if (state === 'waiting') {
+ return (
+
+
+
+
+ {' '}
+ Waiting for verification... (Press ESC or CTRL+C to cancel)
+
+
+ {errorMessage && (
+
+ {errorMessage}
+
+ )}
+
+ Press Enter when verification is complete.
+
+
+ );
+ }
+
+ if (state === 'complete') {
+ return (
+
+ Verification complete
+
+ );
+ }
+
+ return (
+
+
+ Further action is required to use this service.
+
+
+ void handleSelect(choice as ValidationIntent)}
+ />
+
+ {learnMoreUrl && (
+
+
+ Learn more: {learnMoreUrl}
+
+
+ )}
+
+ );
+}
diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx
index 6358c26fa7..1ba8c7dfe3 100644
--- a/packages/cli/src/ui/contexts/UIActionsContext.tsx
+++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx
@@ -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;
diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx
index 80db5782ff..e768ef4796 100644
--- a/packages/cli/src/ui/contexts/UIStateContext.tsx
+++ b/packages/cli/src/ui/contexts/UIStateContext.tsx
@@ -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;
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index 048c7080d8..205e577fd2 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -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(
{
diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts
index a0066becd8..61e53638ec 100644
--- a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts
+++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts
@@ -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();
+ });
+ });
});
diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts
index 4922323567..7f8b8d0f0d 100644
--- a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts
+++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts
@@ -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(null);
+ const [validationRequest, setValidationRequest] =
+ useState(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 => {
+ if (isValidationPending.current) {
+ return 'cancel'; // A validation dialog is already active
+ }
+ isValidationPending.current = true;
+
+ const intent: ValidationIntent = await new Promise(
+ (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,
};
}
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index 579dcf8d3d..2871b9fc30 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -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
@@ -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();
}
diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts
index ba4092ec1a..fdf5e22a4d 100644
--- a/packages/core/src/core/client.ts
+++ b/packages/core/src/core/client.ts
@@ -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,
diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts
index 8b75eedb1b..4a99cf7b7b 100644
--- a/packages/core/src/core/geminiChat.ts
+++ b/packages/core/src/core/geminiChat.ts
@@ -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,
diff --git a/packages/core/src/fallback/types.ts b/packages/core/src/fallback/types.ts
index 223c3abbf5..222c7d72a6 100644
--- a/packages/core/src/fallback/types.ts
+++ b/packages/core/src/fallback/types.ts
@@ -37,3 +37,21 @@ export type FallbackModelHandler = (
fallbackModel: string,
error?: unknown,
) => Promise;
+
+/**
+ * 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;
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 506e602ebf..bc1fd35f28 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -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';
diff --git a/packages/core/src/utils/googleQuotaErrors.test.ts b/packages/core/src/utils/googleQuotaErrors.test.ts
index 5aaaf16b76..e126589d63 100644
--- a/packages/core/src/utils/googleQuotaErrors.test.ts
+++ b/packages/core/src/utils/googleQuotaErrors.test.ts
@@ -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);
+ });
});
diff --git a/packages/core/src/utils/googleQuotaErrors.ts b/packages/core/src/utils/googleQuotaErrors.ts
index 4c1234010f..dfd828f41f 100644
--- a/packages/core/src/utils/googleQuotaErrors.ts
+++ b/packages/core/src/utils/googleQuotaErrors.ts
@@ -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 ||
diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts
index eb9e145c18..cbfa16379f 100644
--- a/packages/core/src/utils/retry.ts
+++ b/packages/core/src/utils/retry.ts
@@ -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;
+ onValidationRequired?: (
+ error: ValidationRequiredError,
+ ) => Promise<'verify' | 'change_auth' | 'cancel'>;
authType?: string;
retryFetchErrors?: boolean;
signal?: AbortSignal;
@@ -144,6 +148,7 @@ export async function retryWithBackoff(
initialDelayMs,
maxDelayMs,
onPersistent429,
+ onValidationRequired,
authType,
shouldRetryOnError,
shouldRetryOnContent,
@@ -220,6 +225,26 @@ export async function retryWithBackoff(
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;