Merge pull request #414 from kaitranntt/dev

fix(release): WebSearch hooks for CLIProxy profiles
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-01 02:51:29 -05:00
committed by GitHub
8 changed files with 756 additions and 15 deletions
+4 -4
View File
@@ -2,9 +2,9 @@
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/claude",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "claude-sonnet-4-20250514",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-20250514",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-20250514",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-3-5-20241022"
"ANTHROPIC_MODEL": "claude-sonnet-4-5-20250929",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-5-20251101",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-5-20250929",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5-20251001"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.33.0",
"version": "7.33.0-dev.5",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
+12 -2
View File
@@ -24,6 +24,7 @@ import {
getRemoteEnvVars,
getProviderConfig,
ensureProviderSettings,
getProviderSettingsPath,
CLIPROXY_DEFAULT_PORT,
getCliproxyWritablePath,
validatePort,
@@ -973,6 +974,7 @@ export async function execClaudeWithCLIProxy(
'--incognito',
'--no-incognito',
'--import',
'--settings', // Block user --settings to prevent duplicate flags
// Proxy flags are handled by resolveProxyConfig, but list for documentation
...PROXY_CLI_FLAGS,
];
@@ -994,9 +996,17 @@ export async function execClaudeWithCLIProxy(
const isWindows = process.platform === 'win32';
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
// Get profile settings path for hooks (WebSearch, etc.)
// Use custom settings path for CLIProxy variants, otherwise use default provider path
const settingsPath = cfg.customSettingsPath
? cfg.customSettingsPath.replace(/^~/, process.env.HOME || '')
: getProviderSettingsPath(provider);
let claude: ChildProcess;
if (needsShell) {
const cmdString = [claudeCli, ...claudeArgs].map(escapeShellArg).join(' ');
const cmdString = [claudeCli, '--settings', settingsPath, ...claudeArgs]
.map(escapeShellArg)
.join(' ');
claude = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
@@ -1004,7 +1014,7 @@ export async function execClaudeWithCLIProxy(
env,
});
} else {
claude = spawn(claudeCli, claudeArgs, {
claude = spawn(claudeCli, ['--settings', settingsPath, ...claudeArgs], {
stdio: 'inherit',
windowsHide: true,
env,
+26 -1
View File
@@ -207,14 +207,39 @@ async function performNpmUpdate(
const performUpdate = (): void => {
// On Windows, use shell with full command string to avoid deprecation warning
// Also suppress Node deprecation warnings that may come from package managers
// Pipe stderr on Windows to filter npm cleanup warnings (EPERM on native modules)
const child = isWindows
? spawn(`${updateCommand} ${updateArgs.join(' ')}`, [], {
stdio: 'inherit',
stdio: ['inherit', 'inherit', 'pipe'],
shell: true,
env: { ...process.env, NODE_NO_WARNINGS: '1' },
})
: spawn(updateCommand, updateArgs, { stdio: 'inherit' });
// On Windows, filter stderr to hide npm cleanup warnings (EPERM on bcrypt.node etc.)
// These warnings are cosmetic - update succeeds despite file locking by antivirus/indexing
// Use line-buffering to handle chunk splitting (data events don't guarantee message boundaries)
if (isWindows && child.stderr) {
let stderrBuffer = '';
child.stderr.on('data', (data: Buffer) => {
stderrBuffer += data.toString();
const lines = stderrBuffer.split('\n');
stderrBuffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
// Skip npm cleanup warnings (EPERM, ENOTEMPTY, EBUSY on native module prebuilds)
if (!/npm warn cleanup/i.test(line)) {
process.stderr.write(line + '\n');
}
}
});
child.stderr.on('close', () => {
// Flush remaining buffer on stream close
if (stderrBuffer && !/npm warn cleanup/i.test(stderrBuffer)) {
process.stderr.write(stderrBuffer);
}
});
}
child.on('exit', (code) => {
if (code === 0) {
console.log('');
+208 -6
View File
@@ -29,6 +29,15 @@ interface GlmtProxyConfig {
timeout?: number;
}
/**
* Configuration for retry behavior on rate limit errors
*/
interface RetryConfig {
maxRetries: number;
baseDelay: number;
enabled: boolean;
}
interface ThinkingConfig {
thinking: boolean;
effort: string;
@@ -79,6 +88,8 @@ export class GlmtProxy {
private port: number | null;
private verbose: boolean;
private timeout: number;
private retryConfig: RetryConfig;
private httpsAgent: https.Agent;
constructor(config: GlmtProxyConfig = {}) {
this.transformer = new GlmtTransformer({
@@ -93,6 +104,23 @@ export class GlmtProxy {
this.port = null;
this.verbose = config.verbose || false;
this.timeout = config.timeout || 120000; // 120s default
// Retry configuration for handling 429 rate limit errors
const maxRetries = parseInt(process.env.GLMT_MAX_RETRIES || '3', 10);
const baseDelay = parseInt(process.env.GLMT_RETRY_BASE_DELAY || '1000', 10);
this.retryConfig = {
maxRetries: isNaN(maxRetries) || maxRetries < 0 ? 3 : Math.min(maxRetries, 10),
baseDelay: isNaN(baseDelay) || baseDelay < 0 ? 1000 : baseDelay,
enabled: process.env.GLMT_DISABLE_RETRY !== '1',
};
// Connection pooling for better performance
this.httpsAgent = new https.Agent({
keepAlive: true,
maxSockets: 5,
maxFreeSockets: 2,
timeout: this.timeout,
});
}
/**
@@ -249,8 +277,8 @@ export class GlmtProxy {
this.log(`Transformed request, thinking: ${thinkingConfig.thinking}`);
// Forward to Z.AI
const openaiResponse = (await this.forwardToUpstream(
// Forward to Z.AI with retry on rate limit
const openaiResponse = (await this.forwardWithRetry(
openaiRequest as unknown as OpenAIRequest,
{}
)) as OpenAIResponse;
@@ -309,8 +337,8 @@ export class GlmtProxy {
this.log('Starting SSE stream to Claude CLI (socket buffering disabled)');
// Forward and stream
await this.forwardAndStreamUpstream(
// Forward and stream with retry on rate limit
await this.forwardAndStreamWithRetry(
openaiRequest as unknown as OpenAIRequest,
{},
res,
@@ -342,6 +370,154 @@ export class GlmtProxy {
});
}
/**
* Sleep for specified milliseconds
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Calculate retry delay with exponential backoff and jitter
* Honors Retry-After header if provided
*/
private calculateRetryDelay(attempt: number, retryAfterHeader?: string): number {
// Honor Retry-After header if present and valid
if (retryAfterHeader) {
const retryAfter = parseInt(retryAfterHeader, 10);
if (!isNaN(retryAfter) && retryAfter > 0) {
return retryAfter * 1000; // Convert seconds to ms
}
}
// Exponential backoff: 2^attempt * baseDelay + random jitter (0-500ms), capped at 30s
const MAX_DELAY_MS = 30000;
const exponentialDelay = Math.min(
Math.pow(2, attempt) * this.retryConfig.baseDelay,
MAX_DELAY_MS
);
const jitter = Math.random() * 500;
return exponentialDelay + jitter;
}
/**
* Check if error is retryable (429 rate limit)
* Returns retryable status and optional Retry-After value
*/
private isRetryableError(error: Error): { retryable: boolean; retryAfter?: string } {
const message = error.message;
if (message.includes('429') || message.toLowerCase().includes('rate limit')) {
// Prefer Retry-After from error property (captured from HTTP header), fallback to parsing message
const errWithRetryAfter = error as Error & { retryAfter?: string };
if (errWithRetryAfter.retryAfter) {
return { retryable: true, retryAfter: errWithRetryAfter.retryAfter };
}
const retryAfterMatch = message.match(/retry-after:\s*(\d+)/i);
return {
retryable: true,
retryAfter: retryAfterMatch?.[1],
};
}
return { retryable: false };
}
/**
* Forward request with retry logic for rate limit errors
*/
private async forwardWithRetry(
openaiRequest: OpenAIRequest,
headers: Record<string, string | undefined>
): Promise<unknown> {
if (!this.retryConfig.enabled) {
return this.forwardToUpstream(openaiRequest, headers);
}
let lastError: Error | null = null;
for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
try {
return await this.forwardToUpstream(openaiRequest, headers);
} catch (error) {
const err = error as Error;
const { retryable, retryAfter } = this.isRetryableError(err);
if (!retryable || attempt >= this.retryConfig.maxRetries) {
throw err;
}
lastError = err;
const delay = this.calculateRetryDelay(attempt, retryAfter);
console.error(
`[glmt-proxy] Rate limited, retry ${attempt + 1}/${this.retryConfig.maxRetries} after ${Math.round(delay)}ms`
);
await this.sleep(delay);
}
}
throw lastError ?? new Error('Retry failed');
}
/**
* Forward streaming request with retry logic for rate limit errors
* Only retries if headers haven't been sent yet
*/
private async forwardAndStreamWithRetry(
openaiRequest: OpenAIRequest,
headers: Record<string, string | undefined>,
clientRes: http.ServerResponse,
thinkingConfig: ThinkingConfig,
startTime: number
): Promise<void> {
if (!this.retryConfig.enabled) {
return this.forwardAndStreamUpstream(
openaiRequest,
headers,
clientRes,
thinkingConfig,
startTime
);
}
let lastError: Error | null = null;
for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
try {
return await this.forwardAndStreamUpstream(
openaiRequest,
headers,
clientRes,
thinkingConfig,
startTime
);
} catch (error) {
const err = error as Error;
// Don't retry if headers already sent (streaming started)
if (clientRes.headersSent) {
throw err;
}
const { retryable, retryAfter } = this.isRetryableError(err);
if (!retryable || attempt >= this.retryConfig.maxRetries) {
throw err;
}
lastError = err;
const delay = this.calculateRetryDelay(attempt, retryAfter);
console.error(
`[glmt-proxy] Rate limited, retry ${attempt + 1}/${this.retryConfig.maxRetries} after ${Math.round(delay)}ms`
);
await this.sleep(delay);
}
}
throw lastError ?? new Error('Retry failed');
}
/**
* Forward request to Z.AI upstream
*/
@@ -362,6 +538,7 @@ export class GlmtProxy {
port: url.port || 443,
path: url.pathname || '/api/coding/paas/v4/chat/completions',
method: 'POST',
agent: this.httpsAgent,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(requestBody),
@@ -392,7 +569,17 @@ export class GlmtProxy {
// Check for non-200 status
if (res.statusCode !== 200) {
reject(new Error(`Upstream error: ${res.statusCode} ${res.statusMessage}\n${body}`));
const err = new Error(
`Upstream error: ${res.statusCode} ${res.statusMessage}\n${body}`
) as Error & { retryAfter?: string };
// Capture Retry-After header for rate limit handling
const retryAfterHeader = res.headers['retry-after'];
if (retryAfterHeader) {
err.retryAfter = Array.isArray(retryAfterHeader)
? retryAfterHeader[0]
: retryAfterHeader;
}
reject(err);
return;
}
@@ -438,6 +625,7 @@ export class GlmtProxy {
port: url.port || 443,
path: url.pathname || '/api/coding/paas/v4/chat/completions',
method: 'POST',
agent: this.httpsAgent,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(requestBody),
@@ -461,7 +649,17 @@ export class GlmtProxy {
let body = '';
upstreamRes.on('data', (chunk: Buffer) => (body += chunk.toString()));
upstreamRes.on('end', () => {
reject(new Error(`Upstream error: ${upstreamRes.statusCode}\n${body}`));
const err = new Error(`Upstream error: ${upstreamRes.statusCode}\n${body}`) as Error & {
retryAfter?: string;
};
// Capture Retry-After header for rate limit handling
const retryAfterHeader = upstreamRes.headers['retry-after'];
if (retryAfterHeader) {
err.retryAfter = Array.isArray(retryAfterHeader)
? retryAfterHeader[0]
: retryAfterHeader;
}
reject(err);
});
return;
}
@@ -537,6 +735,10 @@ export class GlmtProxy {
this.log('Stopping proxy server');
this.server.close();
}
// Destroy connection pool
if (this.httpsAgent) {
this.httpsAgent.destroy();
}
}
/**
+17 -1
View File
@@ -154,7 +154,23 @@ export class ClaudeSymlinkManager {
return true; // Already correct, counts as success
}
// Backup existing file/directory
// On Windows, check if it's a valid copy (symlink fallback from previous sync)
// This prevents creating duplicate backups on every sync
if (process.platform === 'win32' && this.isCopiedItem(targetPath, sourcePath, item.type)) {
// Remove existing copy and refresh with latest source content
try {
if (item.type === 'directory') {
fs.rmSync(targetPath, { recursive: true, force: true });
} else {
fs.unlinkSync(targetPath);
}
} catch {
// If removal fails, proceed to copy which will overwrite
}
return this.copyFallback(sourcePath, targetPath, item, silent);
}
// Backup existing file/directory (only for non-CCS items)
this.backupItem(targetPath, silent);
}
@@ -0,0 +1,212 @@
/**
* Unit Tests for Update Command stderr filtering on Windows
*
* Tests the npm cleanup warning filter logic that hides cosmetic EPERM
* warnings on Windows during `ccs update`. These warnings occur when
* npm fails to unlink native module prebuilds (bcrypt.node) due to
* antivirus/indexing file locking.
*
* @see https://github.com/kaitranntt/ccs/issues/405
*/
const assert = require('assert');
/**
* Simulates the line-buffered stderr filtering logic from update-command.ts
* This is extracted for testability since mocking spawn is complex.
*/
function createStderrFilter() {
let buffer = '';
const output = [];
return {
/**
* Process a chunk of stderr data (simulates 'data' event)
*/
processChunk(chunk) {
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!/npm warn cleanup/i.test(line)) {
output.push(line);
}
}
},
/**
* Flush remaining buffer (simulates 'close' event)
*/
flush() {
if (buffer && !/npm warn cleanup/i.test(buffer)) {
output.push(buffer);
}
buffer = '';
},
/**
* Get filtered output lines
*/
getOutput() {
return output;
},
/**
* Get remaining buffer (for testing)
*/
getBuffer() {
return buffer;
},
};
}
describe('Update Command stderr filter', function () {
describe('npm cleanup warning filtering', function () {
it('filters single npm warn cleanup line', function () {
const filter = createStderrFilter();
filter.processChunk('npm warn cleanup Failed to remove some directories\n');
filter.flush();
assert.deepStrictEqual(filter.getOutput(), []);
});
it('filters multiple npm warn cleanup lines', function () {
const filter = createStderrFilter();
filter.processChunk(
"npm warn cleanup 'C:\\\\Users\\\\...\\\\node_modules\\\\@kaitranntt\\\\.ccs-VzVYv4mp',\n" +
"npm warn cleanup [Error: EPERM: operation not permitted, unlink '...\\\\bcrypt.node']\n" +
'npm warn cleanup errno: -4048,\n' +
"npm warn cleanup code: 'EPERM',\n" +
"npm warn cleanup syscall: 'unlink',\n"
);
filter.flush();
assert.deepStrictEqual(filter.getOutput(), []);
});
it('preserves non-cleanup npm warnings', function () {
const filter = createStderrFilter();
filter.processChunk('npm warn deprecated lodash@1.0.0: Use lodash@4.x instead\n');
filter.flush();
assert.deepStrictEqual(filter.getOutput(), ['npm warn deprecated lodash@1.0.0: Use lodash@4.x instead']);
});
it('preserves npm errors', function () {
const filter = createStderrFilter();
filter.processChunk('npm ERR! code ENOTFOUND\n');
filter.processChunk('npm ERR! network request failed\n');
filter.flush();
assert.deepStrictEqual(filter.getOutput(), ['npm ERR! code ENOTFOUND', 'npm ERR! network request failed']);
});
it('filters cleanup but preserves other warnings in mixed output', function () {
const filter = createStderrFilter();
filter.processChunk(
'npm warn deprecated chalk@4.0.0: Deprecated\n' +
'npm warn cleanup Failed to remove...\n' +
'npm warn peer react@17: Use react@18\n' +
'npm warn cleanup errno: -4048,\n'
);
filter.flush();
assert.deepStrictEqual(filter.getOutput(), [
'npm warn deprecated chalk@4.0.0: Deprecated',
'npm warn peer react@17: Use react@18',
]);
});
it('is case-insensitive for npm warn cleanup', function () {
const filter = createStderrFilter();
filter.processChunk('NPM WARN CLEANUP something\n');
filter.processChunk('Npm Warn Cleanup another\n');
filter.flush();
assert.deepStrictEqual(filter.getOutput(), []);
});
});
describe('chunk boundary handling', function () {
it('handles warning split across chunks', function () {
const filter = createStderrFilter();
// Simulate "npm warn cleanup" split across two chunks
filter.processChunk('npm war');
filter.processChunk('n cleanup Failed to remove\n');
filter.flush();
// Should still filter - line-buffering catches this
assert.deepStrictEqual(filter.getOutput(), []);
});
it('handles multiple lines split across chunks', function () {
const filter = createStderrFilter();
filter.processChunk('npm warn deprecated lodash\n');
filter.processChunk('npm warn cleanup ');
filter.processChunk('EPERM error\n');
filter.processChunk('npm ERR! fatal\n');
filter.flush();
assert.deepStrictEqual(filter.getOutput(), ['npm warn deprecated lodash', 'npm ERR! fatal']);
});
it('buffers incomplete line until newline', function () {
const filter = createStderrFilter();
filter.processChunk('incomplete line without newline');
// Nothing output yet - still buffered
assert.deepStrictEqual(filter.getOutput(), []);
assert.strictEqual(filter.getBuffer(), 'incomplete line without newline');
// Complete the line
filter.processChunk(' - now complete\n');
assert.deepStrictEqual(filter.getOutput(), ['incomplete line without newline - now complete']);
});
it('flushes remaining buffer on close', function () {
const filter = createStderrFilter();
filter.processChunk('final line no newline');
filter.flush();
assert.deepStrictEqual(filter.getOutput(), ['final line no newline']);
});
it('does not flush cleanup warning on close', function () {
const filter = createStderrFilter();
filter.processChunk('npm warn cleanup no newline');
filter.flush();
assert.deepStrictEqual(filter.getOutput(), []);
});
});
describe('edge cases', function () {
it('handles empty input', function () {
const filter = createStderrFilter();
filter.processChunk('');
filter.flush();
assert.deepStrictEqual(filter.getOutput(), []);
});
it('handles only newlines', function () {
const filter = createStderrFilter();
filter.processChunk('\n\n\n');
filter.flush();
assert.deepStrictEqual(filter.getOutput(), ['', '', '']);
});
it('handles Windows CRLF line endings', function () {
const filter = createStderrFilter();
filter.processChunk('npm warn cleanup EPERM\r\nnpm ERR! error\r\n');
filter.flush();
// Note: \r remains but line is still filtered by pattern
// The ERR line has \r at end but that's okay
const output = filter.getOutput();
assert.strictEqual(output.length, 1);
assert.ok(output[0].includes('npm ERR! error'));
});
});
});
+276
View File
@@ -0,0 +1,276 @@
/**
* GLMT Retry Logic Unit Tests
*
* Tests for exponential backoff retry behavior on 429 rate limit errors
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
// Store original env vars
const originalEnv = { ...process.env };
// Reset env before each test
beforeEach(() => {
delete process.env.GLMT_MAX_RETRIES;
delete process.env.GLMT_RETRY_BASE_DELAY;
delete process.env.GLMT_DISABLE_RETRY;
});
afterEach(() => {
// Restore original env
Object.keys(process.env).forEach((key) => {
if (key.startsWith('GLMT_')) {
delete process.env[key];
}
});
Object.assign(process.env, originalEnv);
});
// Helper to create proxy instance with specific config
async function createTestableProxy(config: {
maxRetries?: number;
baseDelay?: number;
enabled?: boolean;
} = {}) {
// Set env vars before import
if (config.maxRetries !== undefined) {
process.env.GLMT_MAX_RETRIES = String(config.maxRetries);
}
if (config.baseDelay !== undefined) {
process.env.GLMT_RETRY_BASE_DELAY = String(config.baseDelay);
}
if (config.enabled === false) {
process.env.GLMT_DISABLE_RETRY = '1';
}
// Dynamic import to pick up env changes
const module = await import('../../../src/glmt/glmt-proxy');
return new module.GlmtProxy({ verbose: false });
}
describe('GLMT Retry Logic', () => {
describe('RetryConfig initialization', () => {
it('should use default values when env vars not set', async () => {
const proxy = await createTestableProxy();
// Access private via type assertion for testing
const config = (proxy as unknown as { retryConfig: { maxRetries: number; baseDelay: number; enabled: boolean } }).retryConfig;
expect(config.maxRetries).toBe(3);
expect(config.baseDelay).toBe(1000);
expect(config.enabled).toBe(true);
});
it('should respect GLMT_MAX_RETRIES env var', async () => {
const proxy = await createTestableProxy({ maxRetries: 5 });
const config = (proxy as unknown as { retryConfig: { maxRetries: number } }).retryConfig;
expect(config.maxRetries).toBe(5);
});
it('should respect GLMT_RETRY_BASE_DELAY env var', async () => {
const proxy = await createTestableProxy({ baseDelay: 2000 });
const config = (proxy as unknown as { retryConfig: { baseDelay: number } }).retryConfig;
expect(config.baseDelay).toBe(2000);
});
it('should disable retry when GLMT_DISABLE_RETRY=1', async () => {
const proxy = await createTestableProxy({ enabled: false });
const config = (proxy as unknown as { retryConfig: { enabled: boolean } }).retryConfig;
expect(config.enabled).toBe(false);
});
});
describe('calculateRetryDelay', () => {
it('should calculate exponential delay with jitter', async () => {
const proxy = await createTestableProxy({ baseDelay: 1000 });
const calcDelay = (proxy as unknown as { calculateRetryDelay: (attempt: number, retryAfter?: string) => number }).calculateRetryDelay.bind(proxy);
// Attempt 0: 2^0 * 1000 = 1000 + jitter (0-500)
const delay0 = calcDelay(0);
expect(delay0).toBeGreaterThanOrEqual(1000);
expect(delay0).toBeLessThan(1500);
// Attempt 1: 2^1 * 1000 = 2000 + jitter (0-500)
const delay1 = calcDelay(1);
expect(delay1).toBeGreaterThanOrEqual(2000);
expect(delay1).toBeLessThan(2500);
// Attempt 2: 2^2 * 1000 = 4000 + jitter (0-500)
const delay2 = calcDelay(2);
expect(delay2).toBeGreaterThanOrEqual(4000);
expect(delay2).toBeLessThan(4500);
});
it('should honor Retry-After header in seconds', async () => {
const proxy = await createTestableProxy();
const calcDelay = (proxy as unknown as { calculateRetryDelay: (attempt: number, retryAfter?: string) => number }).calculateRetryDelay.bind(proxy);
// Retry-After: 5 seconds → 5000ms
const delay = calcDelay(0, '5');
expect(delay).toBe(5000);
});
it('should ignore invalid Retry-After header and fallback to exponential', async () => {
const proxy = await createTestableProxy({ baseDelay: 1000 });
const calcDelay = (proxy as unknown as { calculateRetryDelay: (attempt: number, retryAfter?: string) => number }).calculateRetryDelay.bind(proxy);
// Invalid header falls back to exponential
const delay = calcDelay(0, 'invalid');
expect(delay).toBeGreaterThanOrEqual(1000);
expect(delay).toBeLessThan(1500);
});
it('should ignore zero or negative Retry-After', async () => {
const proxy = await createTestableProxy({ baseDelay: 1000 });
const calcDelay = (proxy as unknown as { calculateRetryDelay: (attempt: number, retryAfter?: string) => number }).calculateRetryDelay.bind(proxy);
const delay = calcDelay(0, '0');
expect(delay).toBeGreaterThanOrEqual(1000);
expect(delay).toBeLessThan(1500);
});
});
describe('isRetryableError', () => {
it('should return true for 429 status code', async () => {
const proxy = await createTestableProxy();
const isRetryable = (proxy as unknown as { isRetryableError: (error: Error) => { retryable: boolean; retryAfter?: string } }).isRetryableError.bind(proxy);
const result = isRetryable(new Error('Upstream error: 429 Too Many Requests'));
expect(result.retryable).toBe(true);
});
it('should return true for rate limit message', async () => {
const proxy = await createTestableProxy();
const isRetryable = (proxy as unknown as { isRetryableError: (error: Error) => { retryable: boolean; retryAfter?: string } }).isRetryableError.bind(proxy);
const result = isRetryable(new Error('Rate limit exceeded'));
expect(result.retryable).toBe(true);
});
it('should return false for non-retryable errors', async () => {
const proxy = await createTestableProxy();
const isRetryable = (proxy as unknown as { isRetryableError: (error: Error) => { retryable: boolean; retryAfter?: string } }).isRetryableError.bind(proxy);
expect(isRetryable(new Error('Connection refused')).retryable).toBe(false);
expect(isRetryable(new Error('Timeout')).retryable).toBe(false);
expect(isRetryable(new Error('500 Internal Server Error')).retryable).toBe(false);
expect(isRetryable(new Error('401 Unauthorized')).retryable).toBe(false);
});
it('should extract Retry-After from error message', async () => {
const proxy = await createTestableProxy();
const isRetryable = (proxy as unknown as { isRetryableError: (error: Error) => { retryable: boolean; retryAfter?: string } }).isRetryableError.bind(proxy);
const result = isRetryable(new Error('429 Too Many Requests, Retry-After: 10'));
expect(result.retryable).toBe(true);
expect(result.retryAfter).toBe('10');
});
});
describe('forwardWithRetry behavior', () => {
it('should succeed on first attempt without retry', async () => {
const proxy = await createTestableProxy();
let attempts = 0;
// Mock forwardToUpstream
(proxy as unknown as { forwardToUpstream: () => Promise<unknown> }).forwardToUpstream = async () => {
attempts++;
return { choices: [{ message: { content: 'success' } }] };
};
const forwardWithRetry = (proxy as unknown as { forwardWithRetry: (req: unknown, headers: unknown) => Promise<unknown> }).forwardWithRetry.bind(proxy);
const result = await forwardWithRetry({}, {});
expect(attempts).toBe(1);
expect((result as { choices: Array<{ message: { content: string } }> }).choices[0].message.content).toBe('success');
});
it('should retry on 429 and succeed eventually', async () => {
const proxy = await createTestableProxy({ baseDelay: 10 }); // Fast for tests
let attempts = 0;
(proxy as unknown as { forwardToUpstream: () => Promise<unknown> }).forwardToUpstream = async () => {
attempts++;
if (attempts < 3) {
throw new Error('Upstream error: 429 Too Many Requests');
}
return { choices: [{ message: { content: 'success after retry' } }] };
};
const forwardWithRetry = (proxy as unknown as { forwardWithRetry: (req: unknown, headers: unknown) => Promise<unknown> }).forwardWithRetry.bind(proxy);
const result = await forwardWithRetry({}, {});
expect(attempts).toBe(3);
expect((result as { choices: Array<{ message: { content: string } }> }).choices[0].message.content).toBe('success after retry');
});
it('should fail after max retries exhausted', async () => {
const proxy = await createTestableProxy({ maxRetries: 2, baseDelay: 10 });
let attempts = 0;
(proxy as unknown as { forwardToUpstream: () => Promise<unknown> }).forwardToUpstream = async () => {
attempts++;
throw new Error('Upstream error: 429 Too Many Requests');
};
const forwardWithRetry = (proxy as unknown as { forwardWithRetry: (req: unknown, headers: unknown) => Promise<unknown> }).forwardWithRetry.bind(proxy);
await expect(forwardWithRetry({}, {})).rejects.toThrow('429');
expect(attempts).toBe(3); // Initial + 2 retries
});
it('should not retry when disabled', async () => {
const proxy = await createTestableProxy({ enabled: false });
let attempts = 0;
(proxy as unknown as { forwardToUpstream: () => Promise<unknown> }).forwardToUpstream = async () => {
attempts++;
throw new Error('Upstream error: 429 Too Many Requests');
};
const forwardWithRetry = (proxy as unknown as { forwardWithRetry: (req: unknown, headers: unknown) => Promise<unknown> }).forwardWithRetry.bind(proxy);
await expect(forwardWithRetry({}, {})).rejects.toThrow('429');
expect(attempts).toBe(1);
});
it('should not retry non-429 errors', async () => {
const proxy = await createTestableProxy({ baseDelay: 10 });
let attempts = 0;
(proxy as unknown as { forwardToUpstream: () => Promise<unknown> }).forwardToUpstream = async () => {
attempts++;
throw new Error('Upstream error: 500 Internal Server Error');
};
const forwardWithRetry = (proxy as unknown as { forwardWithRetry: (req: unknown, headers: unknown) => Promise<unknown> }).forwardWithRetry.bind(proxy);
await expect(forwardWithRetry({}, {})).rejects.toThrow('500');
expect(attempts).toBe(1);
});
});
describe('connection pooling', () => {
it('should create https.Agent with keepAlive enabled', async () => {
const proxy = await createTestableProxy();
const agent = (proxy as unknown as { httpsAgent: { options?: { keepAlive?: boolean } } }).httpsAgent;
expect(agent).toBeDefined();
// Agent should have keepAlive behavior (internal property)
expect(agent.options?.keepAlive).toBe(true);
});
it('should destroy agent on stop', async () => {
const proxy = await createTestableProxy();
const agent = (proxy as unknown as { httpsAgent: { destroy: () => void; destroyed?: boolean } }).httpsAgent;
let destroyed = false;
const originalDestroy = agent.destroy.bind(agent);
agent.destroy = () => {
destroyed = true;
originalDestroy();
};
proxy.stop();
expect(destroyed).toBe(true);
});
});
});