mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 18:15:48 +00:00
fix: apply cliproxy retry settings safely
This commit is contained in:
1 parent
d5ea4eeea3
commit
480b79551b
11 files changed
+830
-127
No files matched your search
@@ -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<typeof mock>;
|
||||||
|
let fetchRetryMock: ReturnType<typeof mock>;
|
||||||
|
|
||||||
|
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<void>((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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -104,4 +104,44 @@ describe('routing-strategy-http', () => {
|
|||||||
'https://proxy.example.com:443/v0/management/routing/strategy'
|
'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;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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<void> = Promise.resolve();
|
||||||
|
|
||||||
|
export function normalizeCliproxyRetryValue(value: unknown): number | null {
|
||||||
|
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeRetryOperation<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
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<number> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<CliproxyRetryValues> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<CliproxyRetryState> {
|
||||||
|
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<CliproxyRetryApplyResult> {
|
||||||
|
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.',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,16 +9,38 @@ import {
|
|||||||
const ROUTING_TIMEOUT_MS = 5000;
|
const ROUTING_TIMEOUT_MS = 5000;
|
||||||
const CLIPROXY_ROUTING_MANAGEMENT_PATH = '/v0/management/routing/strategy';
|
const CLIPROXY_ROUTING_MANAGEMENT_PATH = '/v0/management/routing/strategy';
|
||||||
|
|
||||||
|
export type CliproxyRetryManagementSetting = 'request-retry' | 'max-retry-interval';
|
||||||
|
|
||||||
export function getCliproxyRoutingManagementUrl(target: ProxyTarget): string {
|
export function getCliproxyRoutingManagementUrl(target: ProxyTarget): string {
|
||||||
return buildProxyUrl(target, CLIPROXY_ROUTING_MANAGEMENT_PATH);
|
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(
|
export async function fetchCliproxyRoutingResponse(
|
||||||
target: ProxyTarget,
|
target: ProxyTarget,
|
||||||
method: 'GET' | 'PUT',
|
method: 'GET' | 'PUT',
|
||||||
body?: Record<string, string>
|
body?: Record<string, string>
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const url = getCliproxyRoutingManagementUrl(target);
|
return fetchCliproxyManagementResponse(
|
||||||
|
target,
|
||||||
|
getCliproxyRoutingManagementUrl(target),
|
||||||
|
method,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCliproxyManagementResponse(
|
||||||
|
target: ProxyTarget,
|
||||||
|
url: string,
|
||||||
|
method: 'GET' | 'PUT',
|
||||||
|
body?: Record<string, string | number>
|
||||||
|
): Promise<Response> {
|
||||||
const headers = buildManagementHeaders(
|
const headers = buildManagementHeaders(
|
||||||
target,
|
target,
|
||||||
body ? { 'Content-Type': 'application/json' } : {}
|
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<Response> {
|
||||||
|
return fetchCliproxyManagementResponse(
|
||||||
|
target,
|
||||||
|
getCliproxyRetryManagementUrl(target, setting),
|
||||||
|
method,
|
||||||
|
value === undefined ? undefined : { value }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getCliproxyRoutingTarget(): ProxyTarget {
|
export function getCliproxyRoutingTarget(): ProxyTarget {
|
||||||
return getProxyTarget();
|
return getProxyTarget();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
readCliproxyRoutingState,
|
readCliproxyRoutingState,
|
||||||
readCliproxySessionAffinityState,
|
readCliproxySessionAffinityState,
|
||||||
} from '../../cliproxy/routing/routing-strategy';
|
} from '../../cliproxy/routing/routing-strategy';
|
||||||
|
import {
|
||||||
|
applyCliproxyRetrySettings,
|
||||||
|
normalizeCliproxyRetryValue,
|
||||||
|
readCliproxyRetryState,
|
||||||
|
} from '../../cliproxy/routing/retry-settings';
|
||||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||||
|
|
||||||
const router = Router();
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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;
|
export default router;
|
||||||
@@ -9,6 +9,8 @@ describe('cliproxy routing routes', () => {
|
|||||||
let applyStrategyMock: ReturnType<typeof mock>;
|
let applyStrategyMock: ReturnType<typeof mock>;
|
||||||
let readAffinityStateMock: ReturnType<typeof mock>;
|
let readAffinityStateMock: ReturnType<typeof mock>;
|
||||||
let applyAffinityMock: ReturnType<typeof mock>;
|
let applyAffinityMock: ReturnType<typeof mock>;
|
||||||
|
let readRetryMock: ReturnType<typeof mock>;
|
||||||
|
let applyRetryMock: ReturnType<typeof mock>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
readStateMock = mock(async () => ({
|
readStateMock = mock(async () => ({
|
||||||
@@ -41,6 +43,23 @@ describe('cliproxy routing routes', () => {
|
|||||||
manageable: true,
|
manageable: true,
|
||||||
applied: 'config-only',
|
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', () => ({
|
mock.module('../../../src/cliproxy/routing/routing-strategy', () => ({
|
||||||
readCliproxyRoutingState: readStateMock,
|
readCliproxyRoutingState: readStateMock,
|
||||||
@@ -62,6 +81,12 @@ describe('cliproxy routing routes', () => {
|
|||||||
return null;
|
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(
|
const { default: routingRoutes } = await import(
|
||||||
`../../../src/web-server/routes/cliproxy-routing-routes?test=${Date.now()}-${Math.random()}`
|
`../../../src/web-server/routes/cliproxy-routing-routes?test=${Date.now()}-${Math.random()}`
|
||||||
@@ -180,4 +205,37 @@ describe('cliproxy routing routes', () => {
|
|||||||
applied: 'config-only',
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-muted/20 px-2 py-1.5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[10px] font-medium text-foreground">
|
||||||
|
{t('routingGuidance.retryTitle')}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">
|
||||||
|
{t('routingGuidance.retryHint')}
|
||||||
|
{retryQuery.data ? ` · ${retryQuery.data.source} · ${retryQuery.data.target}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RetryInputs
|
||||||
|
key={`${requestRetry}:${maxRetryInterval}`}
|
||||||
|
requestRetry={requestRetry}
|
||||||
|
maxRetryInterval={maxRetryInterval}
|
||||||
|
disabled={disabled}
|
||||||
|
onUpdate={(nextRequestRetry, nextMaxRetryInterval) =>
|
||||||
|
updateRetry.mutate({
|
||||||
|
request_retry: nextRequestRetry,
|
||||||
|
max_retry_interval: nextMaxRetryInterval,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{statusMessage ? (
|
||||||
|
<div className="rounded-lg border border-border/60 bg-muted/20 px-2 py-1.5 text-[10px] text-muted-foreground">
|
||||||
|
{statusMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string | null>(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 (
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
aria-label={t('routingGuidance.requestRetryLabel')}
|
||||||
|
inputMode="numeric"
|
||||||
|
className="h-6 w-10 rounded border border-border/70 bg-background px-1.5 text-[10px] text-foreground"
|
||||||
|
value={requestRetryInput}
|
||||||
|
onChange={(event) => setRequestRetryInput(event.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-muted-foreground">/</span>
|
||||||
|
<input
|
||||||
|
aria-label={t('routingGuidance.maxRetryIntervalLabel')}
|
||||||
|
inputMode="numeric"
|
||||||
|
className="h-6 w-10 rounded border border-border/70 bg-background px-1.5 text-[10px] text-foreground"
|
||||||
|
value={maxRetryIntervalInput}
|
||||||
|
onChange={(event) => setMaxRetryIntervalInput(event.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{fieldError ? <div className="text-[10px] text-destructive">{fieldError}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,15 +7,10 @@ import type {
|
|||||||
RoutingStrategy,
|
RoutingStrategy,
|
||||||
CliproxySessionAffinityState,
|
CliproxySessionAffinityState,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { useCliproxyRetryConfig, useUpdateCliproxyRetryConfig } from '@/hooks/use-cliproxy';
|
import { CliproxyRetryControl } from './cliproxy-retry-control';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/** Retry fields accept only non-negative integers (matches the CLIProxy schema bounds). */
|
|
||||||
function isValidRetryFieldValue(value: number): boolean {
|
|
||||||
return Number.isInteger(value) && value >= 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RoutingGuidanceCardProps {
|
interface RoutingGuidanceCardProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
@@ -79,17 +74,6 @@ export function RoutingGuidanceCard({
|
|||||||
const pendingAffinityRef = useRef<{ enabled: boolean; ttl: string } | null>(null);
|
const pendingAffinityRef = useRef<{ enabled: boolean; ttl: string } | null>(null);
|
||||||
const suppressNextAffinityBlurRef = useRef(false);
|
const suppressNextAffinityBlurRef = useRef(false);
|
||||||
|
|
||||||
const retryConfigQuery = useCliproxyRetryConfig();
|
|
||||||
const updateRetryConfig = useUpdateCliproxyRetryConfig();
|
|
||||||
const currentRequestRetry = retryConfigQuery.data?.request_retry ?? 0;
|
|
||||||
const currentMaxRetryInterval = retryConfigQuery.data?.max_retry_interval ?? 0;
|
|
||||||
const [requestRetryInput, setRequestRetryInput] = useState(String(currentRequestRetry));
|
|
||||||
const [maxRetryIntervalInput, setMaxRetryIntervalInput] = useState(
|
|
||||||
String(currentMaxRetryInterval)
|
|
||||||
);
|
|
||||||
const [retryFieldError, setRetryFieldError] = useState<string | null>(null);
|
|
||||||
const retryControlDisabled = retryConfigQuery.isLoading || updateRetryConfig.isPending;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelected(currentStrategy);
|
setSelected(currentStrategy);
|
||||||
}, [currentStrategy]);
|
}, [currentStrategy]);
|
||||||
@@ -99,11 +83,6 @@ export function RoutingGuidanceCard({
|
|||||||
setSelectedAffinityTtl(currentAffinityTtl);
|
setSelectedAffinityTtl(currentAffinityTtl);
|
||||||
}, [currentAffinityEnabled, currentAffinityTtl]);
|
}, [currentAffinityEnabled, currentAffinityTtl]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setRequestRetryInput(String(currentRequestRetry));
|
|
||||||
setMaxRetryIntervalInput(String(currentMaxRetryInterval));
|
|
||||||
}, [currentRequestRetry, currentMaxRetryInterval]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSaving || !pendingAffinityRef.current) {
|
if (isSaving || !pendingAffinityRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -144,34 +123,6 @@ export function RoutingGuidanceCard({
|
|||||||
onApplyAffinity({ enabled: selectedAffinityEnabled, ttl: nextTtl });
|
onApplyAffinity({ enabled: selectedAffinityEnabled, ttl: nextTtl });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRetryBlur = () => {
|
|
||||||
const nextRequestRetry = Number(requestRetryInput.trim());
|
|
||||||
const nextMaxRetryInterval = Number(maxRetryIntervalInput.trim());
|
|
||||||
|
|
||||||
if (
|
|
||||||
!isValidRetryFieldValue(nextRequestRetry) ||
|
|
||||||
!isValidRetryFieldValue(nextMaxRetryInterval)
|
|
||||||
) {
|
|
||||||
setRetryFieldError(t('routingGuidance.retryRangeError'));
|
|
||||||
setRequestRetryInput(String(currentRequestRetry));
|
|
||||||
setMaxRetryIntervalInput(String(currentMaxRetryInterval));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setRetryFieldError(null);
|
|
||||||
if (
|
|
||||||
nextRequestRetry === currentRequestRetry &&
|
|
||||||
nextMaxRetryInterval === currentMaxRetryInterval
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateRetryConfig.mutate({
|
|
||||||
request_retry: nextRequestRetry,
|
|
||||||
max_retry_interval: nextMaxRetryInterval,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
const handleApply = (s: RoutingStrategy) => {
|
const handleApply = (s: RoutingStrategy) => {
|
||||||
setSelected(s);
|
setSelected(s);
|
||||||
@@ -325,43 +276,7 @@ export function RoutingGuidanceCard({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-muted/20 px-2 py-1.5">
|
<CliproxyRetryControl />
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-[10px] font-medium text-foreground">
|
|
||||||
{t('routingGuidance.retryTitle')}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-muted-foreground">
|
|
||||||
{t('routingGuidance.retryHint')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
aria-label={t('routingGuidance.requestRetryLabel')}
|
|
||||||
inputMode="numeric"
|
|
||||||
className="h-6 w-10 rounded border border-border/70 bg-background px-1.5 text-[10px] text-foreground"
|
|
||||||
value={requestRetryInput}
|
|
||||||
onChange={(event) => setRequestRetryInput(event.target.value.replace(/\D/g, ''))}
|
|
||||||
onBlur={handleRetryBlur}
|
|
||||||
disabled={retryControlDisabled}
|
|
||||||
/>
|
|
||||||
<span className="text-[10px] text-muted-foreground">/</span>
|
|
||||||
<input
|
|
||||||
aria-label={t('routingGuidance.maxRetryIntervalLabel')}
|
|
||||||
inputMode="numeric"
|
|
||||||
className="h-6 w-10 rounded border border-border/70 bg-background px-1.5 text-[10px] text-foreground"
|
|
||||||
value={maxRetryIntervalInput}
|
|
||||||
onChange={(event) => setMaxRetryIntervalInput(event.target.value.replace(/\D/g, ''))}
|
|
||||||
onBlur={handleRetryBlur}
|
|
||||||
disabled={retryControlDisabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{retryFieldError ? (
|
|
||||||
<div className="rounded-lg border border-destructive/25 bg-destructive/5 px-2 py-1.5 text-[10px] text-destructive">
|
|
||||||
{retryFieldError}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
type CreatePreset,
|
type CreatePreset,
|
||||||
type RoutingStrategy,
|
type RoutingStrategy,
|
||||||
type CliproxySessionAffinityApplyResult,
|
type CliproxySessionAffinityApplyResult,
|
||||||
|
type CliproxyRetryApplyResult,
|
||||||
|
type CliproxyRetryValues,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -106,57 +108,23 @@ export function useUpdateCliproxySessionAffinity() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** CLIProxy request-retry config (config.cliproxy.retry). Defaults to disabled (0/0). */
|
|
||||||
export interface CliproxyRetryConfig {
|
|
||||||
request_retry: number;
|
|
||||||
max_retry_interval: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_CLIPROXY_RETRY_CONFIG: CliproxyRetryConfig = {
|
|
||||||
request_retry: 0,
|
|
||||||
max_retry_interval: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useCliproxyRetryConfig() {
|
export function useCliproxyRetryConfig() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['cliproxy-retry-config'],
|
queryKey: ['cliproxy-retry-config'],
|
||||||
queryFn: async (): Promise<CliproxyRetryConfig> => {
|
queryFn: () => api.cliproxy.getRetrySettings(),
|
||||||
const config = await api.config.get();
|
|
||||||
const cliproxy = config.cliproxy as { retry?: Partial<CliproxyRetryConfig> } | undefined;
|
|
||||||
return {
|
|
||||||
request_retry:
|
|
||||||
cliproxy?.retry?.request_retry ?? DEFAULT_CLIPROXY_RETRY_CONFIG.request_retry,
|
|
||||||
max_retry_interval:
|
|
||||||
cliproxy?.retry?.max_retry_interval ?? DEFAULT_CLIPROXY_RETRY_CONFIG.max_retry_interval,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Save CLIProxy retry config. Reuses the generic unified-config save path
|
|
||||||
* (PUT /config) — retry is a static opt-in default, not a live-proxy setting
|
|
||||||
* like routing.strategy/session_affinity, so it does not need a dedicated
|
|
||||||
* management-API round trip.
|
|
||||||
*/
|
|
||||||
export function useUpdateCliproxyRetryConfig() {
|
export function useUpdateCliproxyRetryConfig() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (retry: CliproxyRetryConfig): Promise<CliproxyRetryConfig> => {
|
mutationFn: (retry: CliproxyRetryValues) => api.cliproxy.updateRetrySettings(retry),
|
||||||
const config = await api.config.get();
|
onSuccess: (result: CliproxyRetryApplyResult) => {
|
||||||
const existingCliproxy = (config.cliproxy ?? {}) as Record<string, unknown>;
|
queryClient.setQueryData(['cliproxy-retry-config'], result);
|
||||||
await api.config.update({
|
|
||||||
...config,
|
|
||||||
cliproxy: { ...existingCliproxy, retry },
|
|
||||||
});
|
|
||||||
return retry;
|
|
||||||
},
|
|
||||||
onSuccess: (retry) => {
|
|
||||||
queryClient.setQueryData(['cliproxy-retry-config'], retry);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-retry-config'] });
|
queryClient.invalidateQueries({ queryKey: ['cliproxy-retry-config'] });
|
||||||
toast.success(t('toasts.cliproxyRetryUpdated'));
|
toast.success(result.message || t('toasts.cliproxyRetryUpdated'));
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
|
|||||||
@@ -550,6 +550,23 @@ export interface CliproxySessionAffinityApplyResult extends CliproxySessionAffin
|
|||||||
applied: 'config-and-live' | 'config-only' | 'unsupported';
|
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 */
|
/** Auth file info for Config tab */
|
||||||
export interface AuthFile {
|
export interface AuthFile {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1327,6 +1344,12 @@ export const api = {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
|
getRetrySettings: () => request<CliproxyRetryState>('/cliproxy/retry'),
|
||||||
|
updateRetrySettings: (data: CliproxyRetryValues) =>
|
||||||
|
request<CliproxyRetryApplyResult>('/cliproxy/retry', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
aiProviders: {
|
aiProviders: {
|
||||||
list: () => request<ListAiProvidersResult>('/cliproxy/ai-providers'),
|
list: () => request<ListAiProvidersResult>('/cliproxy/ai-providers'),
|
||||||
create: (family: AiProviderFamilyId, data: UpsertAiProviderEntryInput) =>
|
create: (family: AiProviderFamilyId, data: UpsertAiProviderEntryInput) =>
|
||||||
|
|||||||
@@ -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(<CliproxyRetryControl />);
|
||||||
|
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(<CliproxyRetryControl />);
|
||||||
|
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(<CliproxyRetryControl />);
|
||||||
|
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(<CliproxyRetryControl />);
|
||||||
|
expect(screen.getByRole('textbox', { name: 'Request retry count' })).toBeDisabled();
|
||||||
|
|
||||||
|
hookState.query.data = { ...hookState.query.data, manageable: true };
|
||||||
|
hookState.mutation.isPending = true;
|
||||||
|
rerender(<CliproxyRetryControl />);
|
||||||
|
expect(screen.getByRole('textbox', { name: 'Request retry count' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user