fix(cliproxy): prevent false remote timeout on reachable proxy

This commit is contained in:
Tam Nhu Tran
2026-02-22 22:38:50 +07:00
parent dcdb2f6284
commit 34292ca7f8
2 changed files with 41 additions and 31 deletions
+32 -22
View File
@@ -196,8 +196,8 @@ function createHttpsAgent(allowSelfSigned: boolean): https.Agent | undefined {
/** /**
* Check health of remote CLIProxyAPI instance * Check health of remote CLIProxyAPI instance
* *
* Uses /v1/models endpoint for health check since CLIProxyAPI doesn't expose /health. * Uses root endpoint (/) for health check since CLIProxyAPI doesn't expose /health.
* This endpoint is always available and returns 200 when the server is operational. * Root is cheap and avoids false negatives from slower model-list endpoints.
* *
* @param config Remote proxy client configuration * @param config Remote proxy client configuration
* @returns RemoteProxyStatus with reachability and latency * @returns RemoteProxyStatus with reachability and latency
@@ -217,14 +217,13 @@ export async function checkRemoteProxy(
}; };
} }
// Use /v1/models as health check - CLIProxyAPI doesn't have /health endpoint // Use root endpoint for liveness check - cheap and available across deployments
const url = buildProxyUrl(host, port, protocol, '/v1/models'); const url = buildProxyUrl(host, port, protocol, '/');
const startTime = Date.now(); const startTime = Date.now();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try { try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
// Build request options // Build request options
const headers: Record<string, string> = { const headers: Record<string, string> = {
Accept: 'application/json', Accept: 'application/json',
@@ -245,8 +244,19 @@ export async function checkRemoteProxy(
// Use native https module for self-signed cert support // Use native https module for self-signed cert support
response = await new Promise<Response>((resolve, reject) => { response = await new Promise<Response>((resolve, reject) => {
const agent = createHttpsAgent(true); const agent = createHttpsAgent(true);
let settled = false;
const settle = (callback: () => void) => {
if (settled) return;
settled = true;
clearTimeout(reqTimeout);
callback();
};
const reqTimeout = setTimeout(() => { const reqTimeout = setTimeout(() => {
reject(new Error('Request timeout')); const timeoutError = new Error('Request timeout');
req.destroy(timeoutError);
settle(() => reject(timeoutError));
}, timeout); }, timeout);
const req = https.request( const req = https.request(
@@ -258,28 +268,28 @@ export async function checkRemoteProxy(
timeout, timeout,
}, },
(res) => { (res) => {
clearTimeout(reqTimeout); // Health check only needs response headers; don't wait for full body.
let data = ''; // This avoids timeout false negatives when servers stream slower payloads.
res.on('data', (chunk) => (data += chunk)); res.resume();
res.on('end', () => { settle(() =>
resolve( resolve(
new Response(data, { new Response(null, {
status: res.statusCode || 500, status: res.statusCode || 500,
statusText: res.statusMessage, statusText: res.statusMessage ?? '',
}) })
); )
}); );
} }
); );
req.on('error', (err) => { req.on('error', (err) => {
clearTimeout(reqTimeout); settle(() => reject(err));
reject(err);
}); });
req.on('timeout', () => { req.on('timeout', () => {
req.destroy(); const timeoutError = new Error('Request timeout');
reject(new Error('Request timeout')); req.destroy(timeoutError);
settle(() => reject(timeoutError));
}); });
req.end(); req.end();
@@ -292,8 +302,6 @@ export async function checkRemoteProxy(
}); });
} }
clearTimeout(timeoutId);
const latencyMs = Date.now() - startTime; const latencyMs = Date.now() - startTime;
// Check for auth failure // Check for auth failure
@@ -328,6 +336,8 @@ export async function checkRemoteProxy(
error: getErrorMessage(errorCode, err.message), error: getErrorMessage(errorCode, err.message),
errorCode, errorCode,
}; };
} finally {
clearTimeout(timeoutId);
} }
} }
@@ -2,9 +2,9 @@
* Unit tests for remote-proxy-client module * Unit tests for remote-proxy-client module
*/ */
import { describe, it, expect } from 'bun:test'; import { describe, it, expect } from 'bun:test';
import type { import {
RemoteProxyClientConfig, type RemoteProxyClientConfig,
RemoteProxyStatus, type RemoteProxyStatus,
} from '../../../src/cliproxy/remote-proxy-client'; } from '../../../src/cliproxy/remote-proxy-client';
// We test the module's type exports and error handling logic // We test the module's type exports and error handling logic
@@ -119,15 +119,15 @@ describe('remote-proxy-client', () => {
}); });
describe('health check URL construction', () => { describe('health check URL construction', () => {
// CLIProxyAPI uses /v1/models for health checks (no /health endpoint) // CLIProxyAPI uses root endpoint for liveness checks (no /health endpoint)
it('should construct correct health check URL pattern using /v1/models', () => { it('should construct correct health check URL pattern using /', () => {
const config: RemoteProxyClientConfig = { const config: RemoteProxyClientConfig = {
host: '192.168.1.100', host: '192.168.1.100',
port: 8317, port: 8317,
protocol: 'http', protocol: 'http',
}; };
const expectedUrl = `${config.protocol}://${config.host}:${config.port}/v1/models`; const expectedUrl = `${config.protocol}://${config.host}:${config.port}/`;
expect(expectedUrl).toBe('http://192.168.1.100:8317/v1/models'); expect(expectedUrl).toBe('http://192.168.1.100:8317/');
}); });
it('should construct HTTPS URL when protocol is https', () => { it('should construct HTTPS URL when protocol is https', () => {
@@ -136,8 +136,8 @@ describe('remote-proxy-client', () => {
port: 443, port: 443,
protocol: 'https', protocol: 'https',
}; };
const expectedUrl = `${config.protocol}://${config.host}:${config.port}/v1/models`; const expectedUrl = `${config.protocol}://${config.host}:${config.port}/`;
expect(expectedUrl).toBe('https://secure.example.com:443/v1/models'); expect(expectedUrl).toBe('https://secure.example.com:443/');
}); });
}); });