diff --git a/src/cliproxy/__tests__/cliproxy-retry-generator.test.ts b/src/cliproxy/__tests__/cliproxy-retry-generator.test.ts new file mode 100644 index 00000000..d84a2bc0 --- /dev/null +++ b/src/cliproxy/__tests__/cliproxy-retry-generator.test.ts @@ -0,0 +1,156 @@ +/** + * CLIProxy request-retry config — generator tests. + * + * Covers: + * 1. Unset retry config produces today's exact request-retry/max-retry-interval bytes (0/0). + * 2. Configured retry values are emitted verbatim into the generated CLIProxy config.yaml. + * 3. Invalid persisted values (negative/non-integer) fall back to 0, matching the + * unset default (defensive: a corrupt config.yaml must not crash the generator). + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +function createTestHome(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-retry-test-')); + const ccsDir = path.join(dir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 1\n', 'utf8'); + return dir; +} + +describe('CLIProxy retry config — generator', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = createTestHome(); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + function regenerate(tag: string): string { + return path.join(tempHome, '.ccs', 'cliproxy', `config-${tag}.yaml`); + } + + it("unset retry config emits request-retry: 0 and max-retry-interval: 0 (today's default)", async () => { + const { regenerateConfig } = await import(`../config/generator?crgen1=${Date.now()}`); + const configPath = regenerate('unset'); + const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.mkdirSync(authDir, { recursive: true }); + + regenerateConfig(8317, { configPath, authDir }); + + const content = fs.readFileSync(configPath, 'utf-8'); + expect(content).toContain('request-retry: 0'); + expect(content).toContain('max-retry-interval: 0'); + }); + + it('unset retry config produces byte-identical output to an explicit 0/0 config', async () => { + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?crgen2a=${Date.now()}` + ); + const { regenerateConfig } = await import(`../config/generator?crgen2a=${Date.now()}`); + + const unsetPath = regenerate('unset-baseline'); + const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(path.dirname(unsetPath), { recursive: true }); + fs.mkdirSync(authDir, { recursive: true }); + regenerateConfig(8317, { configPath: unsetPath, authDir }); + const unsetContent = fs.readFileSync(unsetPath, 'utf-8'); + + mutateConfig((cfg: { cliproxy?: { retry?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.retry = { request_retry: 0, max_retry_interval: 0 }; + }); + invalidateConfigCache(); + + const explicitPath = regenerate('explicit-zero'); + regenerateConfig(8317, { configPath: explicitPath, authDir }); + const explicitContent = fs.readFileSync(explicitPath, 'utf-8'); + + const stripTimestamp = (s: string) => s.replace(/# Generated: .+/g, '# Generated: TIMESTAMP'); + expect(stripTimestamp(explicitContent)).toBe(stripTimestamp(unsetContent)); + }); + + it('configured retry values are emitted into the generated config', async () => { + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?crgen3=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { retry?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.retry = { request_retry: 3, max_retry_interval: 30 }; + }); + invalidateConfigCache(); + + const { regenerateConfig } = await import(`../config/generator?crgen3=${Date.now()}`); + const configPath = regenerate('configured'); + const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.mkdirSync(authDir, { recursive: true }); + + regenerateConfig(8317, { configPath, authDir }); + + const content = fs.readFileSync(configPath, 'utf-8'); + expect(content).toContain('request-retry: 3'); + expect(content).toContain('max-retry-interval: 30'); + }); + + it('only request_retry set falls back to 0 for max_retry_interval', async () => { + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?crgen4=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { retry?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.retry = { request_retry: 5 }; + }); + invalidateConfigCache(); + + const { regenerateConfig } = await import(`../config/generator?crgen4=${Date.now()}`); + const configPath = regenerate('partial'); + const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.mkdirSync(authDir, { recursive: true }); + + regenerateConfig(8317, { configPath, authDir }); + + const content = fs.readFileSync(configPath, 'utf-8'); + expect(content).toContain('request-retry: 5'); + expect(content).toContain('max-retry-interval: 0'); + }); + + it('a negative persisted request_retry falls back to 0 (defensive, does not throw)', async () => { + const { mutateConfig, invalidateConfigCache } = await import( + `../../config/config-loader-facade?crgen5=${Date.now()}` + ); + mutateConfig((cfg: { cliproxy?: { retry?: Record } }) => { + cfg.cliproxy = cfg.cliproxy ?? {}; + cfg.cliproxy.retry = { request_retry: -1, max_retry_interval: 2.5 }; + }); + invalidateConfigCache(); + + const { regenerateConfig } = await import(`../config/generator?crgen5=${Date.now()}`); + const configPath = regenerate('invalid'); + const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.mkdirSync(authDir, { recursive: true }); + + expect(() => regenerateConfig(8317, { configPath, authDir })).not.toThrow(); + + const content = fs.readFileSync(configPath, 'utf-8'); + expect(content).toContain('request-retry: 0'); + expect(content).toContain('max-retry-interval: 0'); + }); +}); diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index 4bddff8a..87523ddb 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -16,6 +16,7 @@ import { getAuthDir, getProviderAuthDir, getConfigPathForPort } from './path-res import { CLIPROXY_DEFAULT_PORT } from './port-manager'; import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; import { getActiveDockerLegacyApiKeys } from '../../docker/docker-key-rotation'; +import { isValidCliproxyRetryValue } from '../../config/schemas/cliproxy'; /** Internal API key for CCS-managed requests */ export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed'; @@ -192,6 +193,25 @@ function getSessionAffinityTtl(): string { return ttl && GO_DURATION_PATTERN.test(ttl) && hasPositiveDuration(ttl) ? ttl : '1h'; } +/** + * Number of times CLIProxy retries a request on a transient error + * (403, 408, 500, 502, 503, 504). Opt-in via config.cliproxy.retry.request_retry; + * defaults to 0 (disabled) to avoid burning quota on multi-account pools. + */ +function getRequestRetry(): number { + const value = loadOrCreateUnifiedConfig().cliproxy?.retry?.request_retry; + return isValidCliproxyRetryValue(value) ? value : 0; +} + +/** + * Maximum wait time in seconds for a cooled-down credential before CLIProxy + * retries. Opt-in via config.cliproxy.retry.max_retry_interval; defaults to 0. + */ +function getMaxRetryInterval(): number { + const value = loadOrCreateUnifiedConfig().cliproxy?.retry?.max_retry_interval; + return isValidCliproxyRetryValue(value) ? value : 0; +} + function normalizeManagementPanelRepository(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; @@ -659,6 +679,8 @@ function generateUnifiedConfigContent( const routingStrategy = getRoutingStrategy(); const sessionAffinityEnabled = getSessionAffinityEnabled(); const sessionAffinityTtl = getSessionAffinityTtl(); + const requestRetry = getRequestRetry(); + const maxRetryInterval = getMaxRetryInterval(); const managementPanelRepository = getManagementPanelRepository(); // Get effective auth tokens (respects user customization) @@ -751,8 +773,8 @@ ${coolingComment} disable-cooling: ${disableCoolingValue} # Auto-retry on transient errors (403, 408, 500, 502, 503, 504) -request-retry: 0 -max-retry-interval: 0 +request-retry: ${requestRetry} +max-retry-interval: ${maxRetryInterval} ${poolRoutingBlock} # Auto-switch accounts on quota exceeded (429) # This enables seamless multi-account rotation when rate limited diff --git a/src/cliproxy/routing/__tests__/retry-settings.test.ts b/src/cliproxy/routing/__tests__/retry-settings.test.ts new file mode 100644 index 00000000..97405fdb --- /dev/null +++ b/src/cliproxy/routing/__tests__/retry-settings.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import type { ProxyTarget } from '../../proxy/proxy-target-resolver'; + +const localTarget: ProxyTarget = { + host: '127.0.0.1', + port: 8317, + protocol: 'http', + isRemote: false, +}; +const remoteTarget: ProxyTarget = { + host: 'proxy.example.com', + port: 443, + protocol: 'https', + isRemote: true, +}; + +describe('CLIProxy retry settings service', () => { + let target: ProxyTarget; + let config: { cliproxy?: { retry?: { request_retry?: number; max_retry_interval?: number } } }; + let regenerateMock: ReturnType; + let fetchRetryMock: ReturnType; + + beforeEach(() => { + target = localTarget; + config = { cliproxy: { retry: { request_retry: 1, max_retry_interval: 10 } } }; + regenerateMock = mock(() => '/tmp/config.yaml'); + fetchRetryMock = mock(); + + mock.module('../../config/generator', () => ({ regenerateConfig: regenerateMock })); + mock.module('../../config/path-resolver', () => ({ + getAuthDir: () => '/tmp/auth', + getConfigPathForPort: () => '/tmp/config.yaml', + })); + mock.module('../../../config/config-loader-facade', () => ({ + loadOrCreateUnifiedConfig: () => structuredClone(config), + mutateConfig: (mutator: (value: typeof config) => void) => { + mutator(config); + return structuredClone(config); + }, + })); + mock.module('../routing-strategy-http', () => ({ + getCliproxyRoutingTarget: () => target, + fetchCliproxyRetryResponse: fetchRetryMock, + getRoutingErrorMessage: async (response: Response, fallback: string) => { + const body = (await response.json().catch(() => null)) as { error?: string } | null; + return body?.error ?? fallback; + }, + })); + }); + + afterEach(() => mock.restore()); + + async function loadService() { + return import(`../retry-settings?test=${Date.now()}-${Math.random()}`) as Promise< + typeof import('../retry-settings') + >; + } + + it('reads both official management endpoints as one live state', async () => { + fetchRetryMock.mockImplementation( + async (_target: ProxyTarget, setting: string) => + new Response(JSON.stringify({ [setting]: setting === 'request-retry' ? 3 : 30 })) + ); + const { readCliproxyRetryState } = await loadService(); + + await expect(readCliproxyRetryState()).resolves.toEqual({ + request_retry: 3, + max_retry_interval: 30, + source: 'live', + target: 'local', + reachable: true, + manageable: true, + }); + expect(fetchRetryMock.mock.calls.map((call) => call[1])).toEqual([ + 'request-retry', + 'max-retry-interval', + ]); + }); + + it('keeps remote updates live-only', async () => { + target = remoteTarget; + fetchRetryMock.mockImplementation( + async (_target: ProxyTarget, setting: string, method: string) => + method === 'GET' + ? new Response(JSON.stringify({ [setting]: setting === 'request-retry' ? 1 : 10 })) + : new Response('{}') + ); + const { applyCliproxyRetrySettings } = await loadService(); + + const result = await applyCliproxyRetrySettings({ + request_retry: 4, + max_retry_interval: 40, + }); + + expect(result.applied).toBe('live'); + expect(config.cliproxy?.retry).toEqual({ request_retry: 1, max_retry_interval: 10 }); + expect(regenerateMock).not.toHaveBeenCalled(); + }); + + it('rolls back the first upstream value when the second PUT fails', async () => { + target = remoteTarget; + const puts: Array<[string, number]> = []; + fetchRetryMock.mockImplementation( + async (_target: ProxyTarget, setting: string, method: string, value?: number) => { + if (method === 'GET') { + return new Response(JSON.stringify({ [setting]: setting === 'request-retry' ? 2 : 20 })); + } + puts.push([setting, value as number]); + if (setting === 'max-retry-interval') { + return new Response(JSON.stringify({ error: 'second write failed' }), { status: 500 }); + } + return new Response('{}'); + } + ); + const { applyCliproxyRetrySettings } = await loadService(); + + await expect( + applyCliproxyRetrySettings({ request_retry: 5, max_retry_interval: 50 }) + ).rejects.toThrow('second write failed'); + expect(puts).toEqual([ + ['request-retry', 5], + ['max-retry-interval', 50], + ['request-retry', 2], + ]); + }); + + it('restores local persisted settings when regeneration fails', async () => { + fetchRetryMock.mockRejectedValue(new Error('offline')); + regenerateMock + .mockImplementationOnce(() => { + throw new Error('write failed'); + }) + .mockImplementationOnce(() => '/tmp/config.yaml'); + const { applyCliproxyRetrySettings } = await loadService(); + + await expect( + applyCliproxyRetrySettings({ request_retry: 9, max_retry_interval: 90 }) + ).rejects.toThrow('Saved retry settings were rolled back'); + expect(config.cliproxy?.retry).toEqual({ request_retry: 1, max_retry_interval: 10 }); + expect(regenerateMock).toHaveBeenCalledTimes(2); + }); + + it('serializes complete pair operations', async () => { + target = remoteTarget; + const events: string[] = []; + let releaseFirstRead: (() => void) | undefined; + const firstReadGate = new Promise((resolve) => { + releaseFirstRead = resolve; + }); + let requestReadCount = 0; + fetchRetryMock.mockImplementation( + async (_target: ProxyTarget, setting: string, method: string, value?: number) => { + events.push(`${method}:${setting}:${value ?? ''}`); + if (method === 'GET' && setting === 'request-retry' && requestReadCount++ === 0) { + await firstReadGate; + } + return method === 'GET' + ? new Response(JSON.stringify({ [setting]: setting === 'request-retry' ? 1 : 10 })) + : new Response('{}'); + } + ); + const { applyCliproxyRetrySettings } = await loadService(); + + const first = applyCliproxyRetrySettings({ request_retry: 2, max_retry_interval: 20 }); + const second = applyCliproxyRetrySettings({ request_retry: 3, max_retry_interval: 30 }); + await Promise.resolve(); + expect(events).toEqual(['GET:request-retry:']); + releaseFirstRead?.(); + await Promise.all([first, second]); + + expect(events).toEqual([ + 'GET:request-retry:', + 'GET:max-retry-interval:', + 'PUT:request-retry:2', + 'PUT:max-retry-interval:20', + 'GET:request-retry:', + 'GET:max-retry-interval:', + 'PUT:request-retry:3', + 'PUT:max-retry-interval:30', + ]); + }); +}); diff --git a/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts b/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts index 5f6682b9..2ae16bc6 100644 --- a/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts +++ b/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts @@ -104,4 +104,44 @@ describe('routing-strategy-http', () => { 'https://proxy.example.com:443/v0/management/routing/strategy' ); }); + + it('builds target-aware retry management URLs', async () => { + const { getCliproxyRetryManagementUrl } = await loadRoutingHttpModule(); + const target: ProxyTarget = { + host: 'proxy.example.com', + port: 443, + protocol: 'https', + isRemote: true, + }; + + expect(getCliproxyRetryManagementUrl(target, 'request-retry')).toBe( + 'https://proxy.example.com:443/v0/management/request-retry' + ); + expect(getCliproxyRetryManagementUrl(target, 'max-retry-interval')).toBe( + 'https://proxy.example.com:443/v0/management/max-retry-interval' + ); + }); + + it('sends retry updates as an integer value payload', async () => { + const originalFetch = globalThis.fetch; + const fetchMock = mock(async () => new Response('{}')); + globalThis.fetch = fetchMock as typeof fetch; + const target: ProxyTarget = { + host: '127.0.0.1', + port: 8317, + protocol: 'http', + isRemote: false, + }; + + try { + const { fetchCliproxyRetryResponse } = await loadRoutingHttpModule(); + await fetchCliproxyRetryResponse(target, 'request-retry', 'PUT', 4); + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:8317/v0/management/request-retry', + expect.objectContaining({ method: 'PUT', body: JSON.stringify({ value: 4 }) }) + ); + } finally { + globalThis.fetch = originalFetch; + } + }); }); diff --git a/src/cliproxy/routing/retry-settings.ts b/src/cliproxy/routing/retry-settings.ts new file mode 100644 index 00000000..9bd9da63 --- /dev/null +++ b/src/cliproxy/routing/retry-settings.ts @@ -0,0 +1,244 @@ +import { regenerateConfig } from '../config/generator'; +import { getAuthDir, getConfigPathForPort } from '../config/path-resolver'; +import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade'; +import type { ProxyTarget } from '../proxy/proxy-target-resolver'; +import { ConfigError, NetworkError } from '../../errors/error-types'; +import { + fetchCliproxyRetryResponse, + getCliproxyRoutingTarget, + getRoutingErrorMessage, + type CliproxyRetryManagementSetting, +} from './routing-strategy-http'; + +export interface CliproxyRetryValues { + request_retry: number; + max_retry_interval: number; +} + +export interface CliproxyRetryState extends CliproxyRetryValues { + source: 'live' | 'config'; + target: 'local' | 'remote'; + reachable: boolean; + manageable: boolean; + message?: string; +} + +export interface CliproxyRetryApplyResult extends CliproxyRetryState { + applied: 'live' | 'live-and-config' | 'config-only'; +} + +const DEFAULT_RETRY_VALUES: CliproxyRetryValues = { + request_retry: 0, + max_retry_interval: 0, +}; + +let retryOperationQueue: Promise = Promise.resolve(); + +export function normalizeCliproxyRetryValue(value: unknown): number | null { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +function serializeRetryOperation(operation: () => Promise): Promise { + const result = retryOperationQueue.then(operation, operation); + retryOperationQueue = result.then( + () => undefined, + () => undefined + ); + return result; +} + +function getConfiguredRetryValues(): CliproxyRetryValues { + const retry = loadOrCreateUnifiedConfig().cliproxy?.retry; + return { + request_retry: + normalizeCliproxyRetryValue(retry?.request_retry) ?? DEFAULT_RETRY_VALUES.request_retry, + max_retry_interval: + normalizeCliproxyRetryValue(retry?.max_retry_interval) ?? + DEFAULT_RETRY_VALUES.max_retry_interval, + }; +} + +async function readLiveRetryValue( + target: ProxyTarget, + setting: CliproxyRetryManagementSetting +): Promise { + const response = await fetchCliproxyRetryResponse(target, setting, 'GET'); + if (!response.ok) { + throw new NetworkError( + await getRoutingErrorMessage( + response, + `Failed to read CLIProxy ${setting} (${response.status})` + ) + ); + } + + const data = (await response.json()) as Record; + const value = normalizeCliproxyRetryValue(data[setting] ?? data.value); + if (value === null) { + throw new NetworkError(`CLIProxy returned an invalid ${setting} value`); + } + return value; +} + +async function readLiveRetryValues(target: ProxyTarget): Promise { + const requestRetry = await readLiveRetryValue(target, 'request-retry'); + const maxRetryInterval = await readLiveRetryValue(target, 'max-retry-interval'); + return { + request_retry: requestRetry, + max_retry_interval: maxRetryInterval, + }; +} + +async function putLiveRetryValue( + target: ProxyTarget, + setting: CliproxyRetryManagementSetting, + value: number +): Promise { + const response = await fetchCliproxyRetryResponse(target, setting, 'PUT', value); + if (!response.ok) { + throw new NetworkError( + await getRoutingErrorMessage( + response, + `Failed to update CLIProxy ${setting} (${response.status})` + ) + ); + } +} + +async function updateLiveRetryValues( + target: ProxyTarget, + values: CliproxyRetryValues, + previousRequestRetry: number +): Promise { + await putLiveRetryValue(target, 'request-retry', values.request_retry); + try { + await putLiveRetryValue(target, 'max-retry-interval', values.max_retry_interval); + } catch (error) { + try { + await putLiveRetryValue(target, 'request-retry', previousRequestRetry); + } catch (rollbackError) { + throw new NetworkError( + `${(error as Error).message}. Failed to roll back request-retry: ${(rollbackError as Error).message}` + ); + } + throw error; + } +} + +function persistLocalRetryValues(target: ProxyTarget, values: CliproxyRetryValues): void { + const previousRetry = loadOrCreateUnifiedConfig().cliproxy?.retry; + const previous = previousRetry ? { ...previousRetry } : undefined; + const configPath = getConfigPathForPort(target.port); + const authDir = getAuthDir(); + + mutateConfig((config) => { + config.cliproxy = config.cliproxy ?? {}; + config.cliproxy.retry = { ...values }; + }); + + try { + regenerateConfig(target.port, { configPath, authDir }); + } catch (error) { + mutateConfig((config) => { + config.cliproxy = config.cliproxy ?? {}; + if (previous) { + config.cliproxy.retry = previous; + } else { + delete config.cliproxy.retry; + } + }); + + try { + regenerateConfig(target.port, { configPath, authDir }); + } catch (rollbackError) { + throw new ConfigError( + `Failed to regenerate CLIProxy config: ${(error as Error).message}. Rollback regeneration also failed: ${(rollbackError as Error).message}` + ); + } + throw new ConfigError( + `Failed to regenerate CLIProxy config: ${(error as Error).message}. Saved retry settings were rolled back.` + ); + } +} + +export function readCliproxyRetryState(): Promise { + return serializeRetryOperation(async () => { + const target = getCliproxyRoutingTarget(); + try { + const values = await readLiveRetryValues(target); + return { + ...values, + source: 'live', + target: target.isRemote ? 'remote' : 'local', + reachable: true, + manageable: true, + }; + } catch (error) { + if (target.isRemote) throw error; + return { + ...getConfiguredRetryValues(), + source: 'config', + target: 'local', + reachable: false, + manageable: true, + message: 'Local CLIProxy is not reachable. Showing the saved startup defaults.', + }; + } + }); +} + +export function applyCliproxyRetrySettings( + values: CliproxyRetryValues +): Promise { + return serializeRetryOperation(async () => { + const target = getCliproxyRoutingTarget(); + let previousLive: CliproxyRetryValues; + + try { + previousLive = await readLiveRetryValues(target); + } catch (error) { + if (target.isRemote) throw error; + persistLocalRetryValues(target, values); + return { + ...values, + source: 'config', + target: 'local', + reachable: false, + manageable: true, + applied: 'config-only', + message: 'Saved the local startup defaults. They will apply the next time CLIProxy starts.', + }; + } + + if (!target.isRemote) { + persistLocalRetryValues(target, values); + } + + try { + await updateLiveRetryValues(target, values, previousLive.request_retry); + } catch (error) { + if (target.isRemote) throw error; + return { + ...values, + source: 'config', + target: 'local', + reachable: true, + manageable: true, + applied: 'config-only', + message: `Saved the local startup defaults, but the running proxy rejected the live update: ${(error as Error).message}`, + }; + } + + return { + ...values, + source: 'live', + target: target.isRemote ? 'remote' : 'local', + reachable: true, + manageable: true, + applied: target.isRemote ? 'live' : 'live-and-config', + message: target.isRemote + ? 'Updated the running remote CLIProxy. Local CCS config was not changed.' + : 'Updated the running local CLIProxy and saved the startup defaults.', + }; + }); +} diff --git a/src/cliproxy/routing/routing-strategy-http.ts b/src/cliproxy/routing/routing-strategy-http.ts index f361eb7e..1de1b57e 100644 --- a/src/cliproxy/routing/routing-strategy-http.ts +++ b/src/cliproxy/routing/routing-strategy-http.ts @@ -9,16 +9,38 @@ import { const ROUTING_TIMEOUT_MS = 5000; const CLIPROXY_ROUTING_MANAGEMENT_PATH = '/v0/management/routing/strategy'; +export type CliproxyRetryManagementSetting = 'request-retry' | 'max-retry-interval'; + export function getCliproxyRoutingManagementUrl(target: ProxyTarget): string { return buildProxyUrl(target, CLIPROXY_ROUTING_MANAGEMENT_PATH); } +export function getCliproxyRetryManagementUrl( + target: ProxyTarget, + setting: CliproxyRetryManagementSetting +): string { + return buildProxyUrl(target, `/v0/management/${setting}`); +} + export async function fetchCliproxyRoutingResponse( target: ProxyTarget, method: 'GET' | 'PUT', body?: Record ): Promise { - const url = getCliproxyRoutingManagementUrl(target); + return fetchCliproxyManagementResponse( + target, + getCliproxyRoutingManagementUrl(target), + method, + body + ); +} + +async function fetchCliproxyManagementResponse( + target: ProxyTarget, + url: string, + method: 'GET' | 'PUT', + body?: Record +): Promise { const headers = buildManagementHeaders( target, body ? { 'Content-Type': 'application/json' } : {} @@ -115,6 +137,20 @@ export async function fetchCliproxyRoutingResponse( }); } +export async function fetchCliproxyRetryResponse( + target: ProxyTarget, + setting: CliproxyRetryManagementSetting, + method: 'GET' | 'PUT', + value?: number +): Promise { + return fetchCliproxyManagementResponse( + target, + getCliproxyRetryManagementUrl(target, setting), + method, + value === undefined ? undefined : { value } + ); +} + export function getCliproxyRoutingTarget(): ProxyTarget { return getProxyTarget(); } diff --git a/src/config/schemas/__tests__/cliproxy-retry.test.ts b/src/config/schemas/__tests__/cliproxy-retry.test.ts new file mode 100644 index 00000000..6d89baac --- /dev/null +++ b/src/config/schemas/__tests__/cliproxy-retry.test.ts @@ -0,0 +1,48 @@ +/** + * Tests: CLIProxy retry config schema bounds (request_retry, max_retry_interval). + * + * Verifies the opt-in contract: both fields accept only non-negative integers. + */ + +import { describe, it, expect } from 'bun:test'; +import { + isValidCliproxyRetryValue, + CLIPROXY_RETRY_MIN_VALUE, + CLIPROXY_RETRY_RANGE_MESSAGE, +} from '../cliproxy'; + +describe('isValidCliproxyRetryValue', () => { + it('accepts 0 (the disabled default)', () => { + expect(isValidCliproxyRetryValue(0)).toBe(true); + }); + + it('accepts positive integers', () => { + expect(isValidCliproxyRetryValue(3)).toBe(true); + expect(isValidCliproxyRetryValue(30)).toBe(true); + }); + + it('rejects negative numbers', () => { + expect(isValidCliproxyRetryValue(-1)).toBe(false); + }); + + it('rejects non-integer numbers', () => { + expect(isValidCliproxyRetryValue(1.5)).toBe(false); + }); + + it('rejects non-number types', () => { + expect(isValidCliproxyRetryValue('3')).toBe(false); + expect(isValidCliproxyRetryValue(undefined)).toBe(false); + expect(isValidCliproxyRetryValue(null)).toBe(false); + expect(isValidCliproxyRetryValue(true)).toBe(false); + }); + + it('rejects NaN and Infinity', () => { + expect(isValidCliproxyRetryValue(Number.NaN)).toBe(false); + expect(isValidCliproxyRetryValue(Number.POSITIVE_INFINITY)).toBe(false); + }); + + it('exposes the accepted-range constants used in recoverable error messages', () => { + expect(CLIPROXY_RETRY_MIN_VALUE).toBe(0); + expect(CLIPROXY_RETRY_RANGE_MESSAGE).toContain('0'); + }); +}); diff --git a/src/config/schemas/cliproxy.ts b/src/config/schemas/cliproxy.ts index dfc50bd7..92da2cb9 100644 --- a/src/config/schemas/cliproxy.ts +++ b/src/config/schemas/cliproxy.ts @@ -122,6 +122,33 @@ export interface CLIProxyRoutingConfig { session_affinity_ttl?: string; } +/** + * CLIProxy request-retry configuration. + * Controls CLIProxy's own retry-on-transient-error behavior + * (403, 408, 500, 502, 503, 504). Defaults to disabled (0/0): retrying can + * burn quota on multi-account pools, so this is opt-in. + */ +export interface CLIProxyRetryConfig { + /** Number of times to retry a request on a transient error (integer >= 0, default: 0 = disabled) */ + request_retry?: number; + /** Maximum wait time in seconds for a cooled-down credential before retrying (integer >= 0, default: 0) */ + max_retry_interval?: number; +} + +/** Lower bound (inclusive) accepted for CLIProxy retry config fields. */ +export const CLIPROXY_RETRY_MIN_VALUE = 0; + +/** Human-readable accepted-range description for CLIProxy retry config fields. */ +export const CLIPROXY_RETRY_RANGE_MESSAGE = `must be a whole number ${CLIPROXY_RETRY_MIN_VALUE} or greater`; + +/** + * Validate a single CLIProxy retry config field value (request_retry or + * max_retry_interval). Both fields share the same bounds: non-negative integer. + */ +export function isValidCliproxyRetryValue(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= CLIPROXY_RETRY_MIN_VALUE; +} + /** * Pool routing configuration for multi-account CLIProxy rotation. * @@ -188,4 +215,6 @@ export interface CLIProxyConfig { routing?: CLIProxyRoutingConfig; /** Pool routing opt-in state and configuration */ pool_routing?: CLIProxyPoolRoutingConfig; + /** Request-retry behavior for transient upstream errors (opt-in, default: disabled) */ + retry?: CLIProxyRetryConfig; } diff --git a/src/web-server/routes/cliproxy-routing-routes.ts b/src/web-server/routes/cliproxy-routing-routes.ts index e9bf9e22..d104bd43 100644 --- a/src/web-server/routes/cliproxy-routing-routes.ts +++ b/src/web-server/routes/cliproxy-routing-routes.ts @@ -8,6 +8,11 @@ import { readCliproxyRoutingState, readCliproxySessionAffinityState, } from '../../cliproxy/routing/routing-strategy'; +import { + applyCliproxyRetrySettings, + normalizeCliproxyRetryValue, + readCliproxyRetryState, +} from '../../cliproxy/routing/retry-settings'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; const router = Router(); @@ -84,4 +89,34 @@ router.put('/routing/session-affinity', async (req: Request, res: Response): Pro } }); +router.get('/retry', async (_req: Request, res: Response): Promise => { + try { + res.json(await readCliproxyRetryState()); + } catch (error) { + res.status(502).json({ error: (error as Error).message }); + } +}); + +router.put('/retry', async (req: Request, res: Response): Promise => { + const requestRetry = normalizeCliproxyRetryValue(req.body?.request_retry); + const maxRetryInterval = normalizeCliproxyRetryValue(req.body?.max_retry_interval); + if (requestRetry === null || maxRetryInterval === null) { + res.status(400).json({ + error: 'Invalid retry payload. Use non-negative safe integers for both retry fields.', + }); + return; + } + + try { + res.json( + await applyCliproxyRetrySettings({ + request_retry: requestRetry, + max_retry_interval: maxRetryInterval, + }) + ); + } catch (error) { + res.status(502).json({ error: (error as Error).message }); + } +}); + export default router; diff --git a/tests/unit/web-server/cliproxy-routing-routes.test.ts b/tests/unit/web-server/cliproxy-routing-routes.test.ts index 1c828b84..86038868 100644 --- a/tests/unit/web-server/cliproxy-routing-routes.test.ts +++ b/tests/unit/web-server/cliproxy-routing-routes.test.ts @@ -9,6 +9,8 @@ describe('cliproxy routing routes', () => { let applyStrategyMock: ReturnType; let readAffinityStateMock: ReturnType; let applyAffinityMock: ReturnType; + let readRetryMock: ReturnType; + let applyRetryMock: ReturnType; beforeEach(async () => { readStateMock = mock(async () => ({ @@ -41,6 +43,23 @@ describe('cliproxy routing routes', () => { manageable: true, applied: 'config-only', })); + readRetryMock = mock(async () => ({ + request_retry: 2, + max_retry_interval: 20, + source: 'live', + target: 'local', + reachable: true, + manageable: true, + })); + applyRetryMock = mock(async () => ({ + request_retry: 3, + max_retry_interval: 30, + source: 'live', + target: 'local', + reachable: true, + manageable: true, + applied: 'live-and-config', + })); mock.module('../../../src/cliproxy/routing/routing-strategy', () => ({ readCliproxyRoutingState: readStateMock, @@ -62,6 +81,12 @@ describe('cliproxy routing routes', () => { return null; }, })); + mock.module('../../../src/cliproxy/routing/retry-settings', () => ({ + readCliproxyRetryState: readRetryMock, + applyCliproxyRetrySettings: applyRetryMock, + normalizeCliproxyRetryValue: (value: unknown) => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null, + })); const { default: routingRoutes } = await import( `../../../src/web-server/routes/cliproxy-routing-routes?test=${Date.now()}-${Math.random()}` @@ -180,4 +205,37 @@ describe('cliproxy routing routes', () => { applied: 'config-only', }); }); + + it('returns and updates retry settings through the dedicated route', async () => { + const readResponse = await fetch(`${baseUrl}/api/cliproxy/retry`); + expect(readResponse.status).toBe(200); + expect((await readResponse.json()).request_retry).toBe(2); + + const updateResponse = await fetch(`${baseUrl}/api/cliproxy/retry`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ request_retry: 3, max_retry_interval: 30 }), + }); + expect(updateResponse.status).toBe(200); + expect(applyRetryMock).toHaveBeenCalledWith({ + request_retry: 3, + max_retry_interval: 30, + }); + }); + + it.each([ + { request_retry: -1, max_retry_interval: 30 }, + { request_retry: 1.5, max_retry_interval: 30 }, + { request_retry: Number.MAX_SAFE_INTEGER + 1, max_retry_interval: 30 }, + { request_retry: 1, max_retry_interval: '30' }, + ])('rejects invalid retry payload %#', async (payload) => { + const response = await fetch(`${baseUrl}/api/cliproxy/retry`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + expect(response.status).toBe(400); + expect(applyRetryMock).not.toHaveBeenCalled(); + }); }); diff --git a/ui/src/components/cliproxy/cliproxy-retry-control.tsx b/ui/src/components/cliproxy/cliproxy-retry-control.tsx new file mode 100644 index 00000000..830acb2c --- /dev/null +++ b/ui/src/components/cliproxy/cliproxy-retry-control.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useCliproxyRetryConfig, useUpdateCliproxyRetryConfig } from '@/hooks/use-cliproxy'; + +function parseRetryValue(value: string): number | null { + if (!/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +export function CliproxyRetryControl() { + const { t } = useTranslation(); + const retryQuery = useCliproxyRetryConfig(); + const updateRetry = useUpdateCliproxyRetryConfig(); + const requestRetry = retryQuery.data?.request_retry ?? 0; + const maxRetryInterval = retryQuery.data?.max_retry_interval ?? 0; + const manageable = retryQuery.data?.manageable !== false; + const disabled = + retryQuery.isLoading || retryQuery.isError || updateRetry.isPending || !manageable; + const statusMessage = retryQuery.error?.message ?? retryQuery.data?.message; + + return ( +
+
+
+
+ {t('routingGuidance.retryTitle')} +
+
+ {t('routingGuidance.retryHint')} + {retryQuery.data ? ` · ${retryQuery.data.source} · ${retryQuery.data.target}` : ''} +
+
+ + updateRetry.mutate({ + request_retry: nextRequestRetry, + max_retry_interval: nextMaxRetryInterval, + }) + } + /> +
+ {statusMessage ? ( +
+ {statusMessage} +
+ ) : null} +
+ ); +} + +interface RetryInputsProps { + requestRetry: number; + maxRetryInterval: number; + disabled: boolean; + onUpdate: (requestRetry: number, maxRetryInterval: number) => void; +} + +function RetryInputs({ requestRetry, maxRetryInterval, disabled, onUpdate }: RetryInputsProps) { + const { t } = useTranslation(); + const [requestRetryInput, setRequestRetryInput] = useState(String(requestRetry)); + const [maxRetryIntervalInput, setMaxRetryIntervalInput] = useState(String(maxRetryInterval)); + const [fieldError, setFieldError] = useState(null); + + const handleBlur = () => { + if (disabled) return; + const nextRequestRetry = parseRetryValue(requestRetryInput.trim()); + const nextMaxRetryInterval = parseRetryValue(maxRetryIntervalInput.trim()); + if (nextRequestRetry === null || nextMaxRetryInterval === null) { + setFieldError(t('routingGuidance.retryRangeError')); + setRequestRetryInput(String(requestRetry)); + setMaxRetryIntervalInput(String(maxRetryInterval)); + return; + } + + setFieldError(null); + if (nextRequestRetry !== requestRetry || nextMaxRetryInterval !== maxRetryInterval) { + onUpdate(nextRequestRetry, nextMaxRetryInterval); + } + }; + + return ( +
+
+ setRequestRetryInput(event.target.value)} + onBlur={handleBlur} + disabled={disabled} + /> + / + setMaxRetryIntervalInput(event.target.value)} + onBlur={handleBlur} + disabled={disabled} + /> +
+ {fieldError ?
{fieldError}
: null} +
+ ); +} diff --git a/ui/src/components/cliproxy/routing-guidance-card.tsx b/ui/src/components/cliproxy/routing-guidance-card.tsx index 18db639b..69e52caf 100644 --- a/ui/src/components/cliproxy/routing-guidance-card.tsx +++ b/ui/src/components/cliproxy/routing-guidance-card.tsx @@ -7,6 +7,7 @@ import type { RoutingStrategy, CliproxySessionAffinityState, } from '@/lib/api-client'; +import { CliproxyRetryControl } from './cliproxy-retry-control'; import { cn } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; @@ -274,6 +275,8 @@ export function RoutingGuidanceCard({ {sessionAffinityState.message} ) : null} + + ); } diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index 98c23f55..b7a03751 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -12,6 +12,8 @@ import { type CreatePreset, type RoutingStrategy, type CliproxySessionAffinityApplyResult, + type CliproxyRetryApplyResult, + type CliproxyRetryValues, } from '@/lib/api-client'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; @@ -106,6 +108,30 @@ export function useUpdateCliproxySessionAffinity() { }); } +export function useCliproxyRetryConfig() { + return useQuery({ + queryKey: ['cliproxy-retry-config'], + queryFn: () => api.cliproxy.getRetrySettings(), + }); +} + +export function useUpdateCliproxyRetryConfig() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: (retry: CliproxyRetryValues) => api.cliproxy.updateRetrySettings(retry), + onSuccess: (result: CliproxyRetryApplyResult) => { + queryClient.setQueryData(['cliproxy-retry-config'], result); + queryClient.invalidateQueries({ queryKey: ['cliproxy-retry-config'] }); + toast.success(result.message || t('toasts.cliproxyRetryUpdated')); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + export function useCreateVariant() { const queryClient = useQueryClient(); const { t } = useTranslation(); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index f8b12736..15d42477 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -550,6 +550,23 @@ export interface CliproxySessionAffinityApplyResult extends CliproxySessionAffin applied: 'config-and-live' | 'config-only' | 'unsupported'; } +export interface CliproxyRetryValues { + request_retry: number; + max_retry_interval: number; +} + +export interface CliproxyRetryState extends CliproxyRetryValues { + source: 'live' | 'config'; + target: 'local' | 'remote'; + reachable: boolean; + manageable: boolean; + message?: string; +} + +export interface CliproxyRetryApplyResult extends CliproxyRetryState { + applied: 'live' | 'live-and-config' | 'config-only'; +} + /** Auth file info for Config tab */ export interface AuthFile { name: string; @@ -1327,6 +1344,12 @@ export const api = { method: 'PUT', body: JSON.stringify(data), }), + getRetrySettings: () => request('/cliproxy/retry'), + updateRetrySettings: (data: CliproxyRetryValues) => + request('/cliproxy/retry', { + method: 'PUT', + body: JSON.stringify(data), + }), aiProviders: { list: () => request('/cliproxy/ai-providers'), create: (family: AiProviderFamilyId, data: UpsertAiProviderEntryInput) => diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index d5daee9d..f75f9118 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -1957,6 +1957,11 @@ const resources = { sessionRecognitionTitle: 'Session recognition', sessionRecognitionDescription: 'CCS does not promise one universal precedence order here. In practice, upstream backends prefer explicit session or thread IDs first, then fall back to metadata fields and finally a hash based on the opening prompt history.', + retryTitle: 'Auto-retry', + retryHint: 'Retries / max wait (s)', + requestRetryLabel: 'Request retry count', + maxRetryIntervalLabel: 'Max retry interval (seconds)', + retryRangeError: 'Must be a whole number, 0 or greater.', }, extendedContext: { extendedContext: 'Extended Context', @@ -2350,6 +2355,7 @@ const resources = { noProfilesToSync: 'No profiles to sync', syncFailed: 'Sync failed: {{error}}', sessionAffinityUpdated: 'Session affinity {{state}}.', + cliproxyRetryUpdated: 'Retry settings updated.', providerAuthSuccess: '{{provider}} authentication successful', providerDeviceCodeInCallback: 'Provider returned Device Code flow in callback mode', providerAuthTimeout: 'Authentication timed out. Please try again.', @@ -4593,6 +4599,11 @@ const resources = { sessionRecognitionTitle: '会话识别', sessionRecognitionDescription: 'CCS 不承诺所有后端都使用同一优先级顺序。通常上游会优先使用显式会话或线程 ID,然后回退到元数据字段,最后再回退到基于开场提示历史的哈希。', + retryTitle: '自动重试', + retryHint: '重试次数 / 最大等待(秒)', + requestRetryLabel: '重试次数', + maxRetryIntervalLabel: '最大重试间隔(秒)', + retryRangeError: '必须是 0 或更大的整数。', }, extendedContext: { extendedContext: '扩展上下文', @@ -4969,6 +4980,7 @@ const resources = { noProfilesToSync: '没有可同步的配置', syncFailed: '同步失败:{{error}}', sessionAffinityUpdated: '会话粘性已{{state}}。', + cliproxyRetryUpdated: '重试设置已更新。', providerAuthSuccess: '{{provider}} 认证成功', providerDeviceCodeInCallback: '提供商在回调模式中返回了设备码流程', providerAuthTimeout: '认证超时,请重试。', @@ -7304,6 +7316,11 @@ const resources = { sessionRecognitionTitle: 'Nhận diện phiên', sessionRecognitionDescription: 'CCS không cam kết một thứ tự ưu tiên chung cho mọi backend. Trên thực tế, backend upstream thường ưu tiên session hoặc thread id tường minh, rồi mới fallback sang metadata và cuối cùng là hàm băm của lịch sử prompt mở đầu.', + retryTitle: 'Tự động thử lại', + retryHint: 'Số lần thử lại / chờ tối đa (giây)', + requestRetryLabel: 'Số lần thử lại yêu cầu', + maxRetryIntervalLabel: 'Khoảng chờ thử lại tối đa (giây)', + retryRangeError: 'Phải là số nguyên từ 0 trở lên.', }, extendedContext: { extendedContext: 'Ngữ cảnh mở rộng', @@ -7685,6 +7702,7 @@ const resources = { noProfilesToSync: 'Không có hồ sơ để đồng bộ', syncFailed: 'Đồng bộ thất bại: {{error}}', sessionAffinityUpdated: 'Ghim phiên đã {{state}}.', + cliproxyRetryUpdated: 'Đã cập nhật cài đặt thử lại.', providerAuthSuccess: 'Xác thực {{provider}} thành công', providerDeviceCodeInCallback: 'Nhà cung cấp trả về Device Code flow trong chế độ callback', providerAuthTimeout: 'Đã hết thời gian xác thực. Vui lòng thử lại.', @@ -10487,6 +10505,11 @@ const resources = { sessionRecognitionTitle: 'セッション認識', sessionRecognitionDescription: 'CCS はすべてのバックエンドで同一の優先順位を保証しません。一般に上流バックエンドは明示的なセッション / スレッド ID を優先し、その後にメタデータ、最後に冒頭プロンプト履歴のハッシュへフォールバックします。', + retryTitle: '自動リトライ', + retryHint: 'リトライ回数 / 最大待機(秒)', + requestRetryLabel: 'リトライ回数', + maxRetryIntervalLabel: '最大リトライ間隔(秒)', + retryRangeError: '0 以上の整数を入力してください。', }, settingsDialog: { editProfile: 'プロファイルを編集: {{name}}', @@ -10718,6 +10741,7 @@ const resources = { noProfilesToSync: '同期するプロファイルがありません', syncFailed: '同期に失敗しました: {{error}}', sessionAffinityUpdated: 'セッション固定を{{state}}にしました。', + cliproxyRetryUpdated: '再試行設定を更新しました。', providerAuthSuccess: '{{provider}} の認証に成功しました', providerDeviceCodeInCallback: 'コールバックモードでプロバイダーがデバイスコードフローを返しました', @@ -12777,6 +12801,11 @@ const resources = { sessionRecognitionTitle: '세션 인식', sessionRecognitionDescription: 'CCS는 여기서 보편적인 우선순위를 약속하지 않습니다. 실제로 업스트림 백엔드는 명시적 세션 또는 스레드 ID를 먼저 선호한 다음, 메타데이터 필드로 폴백하고 최종적으로 시작 프롬프트 기록 기반 해시로 폴백합니다.', + retryTitle: '자동 재시도', + retryHint: '재시도 횟수 / 최대 대기(초)', + requestRetryLabel: '재시도 횟수', + maxRetryIntervalLabel: '최대 재시도 간격(초)', + retryRangeError: '0 이상의 정수여야 합니다.', }, extendedContext: { extendedContext: '확장 컨텍스트', @@ -13173,6 +13202,7 @@ const resources = { noProfilesToSync: '동기화할 프로필이 없습니다', syncFailed: '동기화 실패: {{error}}', sessionAffinityUpdated: '세션 어피니티 {{state}}.', + cliproxyRetryUpdated: '재시도 설정이 업데이트되었습니다.', providerAuthSuccess: '{{provider}} 인증 성공', providerDeviceCodeInCallback: '프로바이더가 콜백 모드에서 디바이스 코드 흐름을 반환했습니다', diff --git a/ui/tests/unit/components/cliproxy/cliproxy-retry-control.test.tsx b/ui/tests/unit/components/cliproxy/cliproxy-retry-control.test.tsx new file mode 100644 index 00000000..fc467377 --- /dev/null +++ b/ui/tests/unit/components/cliproxy/cliproxy-retry-control.test.tsx @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '../../../setup/test-utils'; +import { CliproxyRetryControl } from '@/components/cliproxy/cliproxy-retry-control'; + +const hookState = vi.hoisted(() => ({ + query: { + data: { + request_retry: 2, + max_retry_interval: 20, + source: 'live' as const, + target: 'local' as const, + reachable: true, + manageable: true, + message: undefined as string | undefined, + }, + isLoading: false, + isError: false, + error: null as Error | null, + }, + mutation: { + isPending: false, + mutate: vi.fn(), + }, +})); + +vi.mock('@/hooks/use-cliproxy', () => ({ + useCliproxyRetryConfig: () => hookState.query, + useUpdateCliproxyRetryConfig: () => hookState.mutation, +})); + +describe('CliproxyRetryControl', () => { + beforeEach(() => { + hookState.query.data = { + request_retry: 2, + max_retry_interval: 20, + source: 'live', + target: 'local', + reachable: true, + manageable: true, + message: undefined, + }; + hookState.query.isLoading = false; + hookState.query.isError = false; + hookState.query.error = null; + hookState.mutation.isPending = false; + hookState.mutation.mutate.mockReset(); + }); + + it('updates the pair through the dedicated retry mutation', () => { + render(); + const requestRetry = screen.getByRole('textbox', { name: 'Request retry count' }); + fireEvent.change(requestRetry, { target: { value: '4' } }); + fireEvent.blur(requestRetry); + + expect(hookState.mutation.mutate).toHaveBeenCalledWith({ + request_retry: 4, + max_retry_interval: 20, + }); + expect(screen.getByText(/live · local/i)).toBeInTheDocument(); + }); + + it('rejects values outside the safe non-negative integer range', () => { + render(); + const requestRetry = screen.getByRole('textbox', { name: 'Request retry count' }); + fireEvent.change(requestRetry, { target: { value: String(Number.MAX_SAFE_INTEGER + 1) } }); + fireEvent.blur(requestRetry); + + expect(hookState.mutation.mutate).not.toHaveBeenCalled(); + expect(screen.getByText('Must be a whole number, 0 or greater.')).toBeInTheDocument(); + }); + + it('disables editing on query errors, unmanageable state, or a pending update', () => { + hookState.query.isError = true; + hookState.query.error = new Error('Retry management unavailable'); + const { rerender } = render(); + expect(screen.getByRole('textbox', { name: 'Request retry count' })).toBeDisabled(); + expect(screen.getByText('Retry management unavailable')).toBeInTheDocument(); + + hookState.query.isError = false; + hookState.query.error = null; + hookState.query.data = { ...hookState.query.data, manageable: false }; + rerender(); + expect(screen.getByRole('textbox', { name: 'Request retry count' })).toBeDisabled(); + + hookState.query.data = { ...hookState.query.data, manageable: true }; + hookState.mutation.isPending = true; + rerender(); + expect(screen.getByRole('textbox', { name: 'Request retry count' })).toBeDisabled(); + }); +});