mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 02:19:39 +00:00
fix(cliproxy): prevent false remote timeout on reachable proxy
This commit is contained in:
@@ -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/');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user