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