diff --git a/config/base-claude.settings.json b/config/base-claude.settings.json index 739df901..ec3525f4 100644 --- a/config/base-claude.settings.json +++ b/config/base-claude.settings.json @@ -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" } } diff --git a/package.json b/package.json index cf604ea4..d2d1f515 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 18e4faa4..c3876358 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -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, diff --git a/src/commands/update-command.ts b/src/commands/update-command.ts index 3a025ff7..b9f3d1bc 100644 --- a/src/commands/update-command.ts +++ b/src/commands/update-command.ts @@ -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(''); diff --git a/src/glmt/glmt-proxy.ts b/src/glmt/glmt-proxy.ts index d1170f6e..85d8c22f 100644 --- a/src/glmt/glmt-proxy.ts +++ b/src/glmt/glmt-proxy.ts @@ -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 { + 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 + ): Promise { + 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, + clientRes: http.ServerResponse, + thinkingConfig: ThinkingConfig, + startTime: number + ): Promise { + 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(); + } } /** diff --git a/src/utils/claude-symlink-manager.ts b/src/utils/claude-symlink-manager.ts index 4bc2d92f..50233981 100644 --- a/src/utils/claude-symlink-manager.ts +++ b/src/utils/claude-symlink-manager.ts @@ -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); } diff --git a/tests/unit/commands/update-command-stderr-filter.test.js b/tests/unit/commands/update-command-stderr-filter.test.js new file mode 100644 index 00000000..db1beab5 --- /dev/null +++ b/tests/unit/commands/update-command-stderr-filter.test.js @@ -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')); + }); + }); +}); diff --git a/tests/unit/glmt/retry-logic.test.ts b/tests/unit/glmt/retry-logic.test.ts new file mode 100644 index 00000000..ab289fd1 --- /dev/null +++ b/tests/unit/glmt/retry-logic.test.ts @@ -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 }).forwardToUpstream = async () => { + attempts++; + return { choices: [{ message: { content: 'success' } }] }; + }; + + const forwardWithRetry = (proxy as unknown as { forwardWithRetry: (req: unknown, headers: unknown) => Promise }).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 }).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 }).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 }).forwardToUpstream = async () => { + attempts++; + throw new Error('Upstream error: 429 Too Many Requests'); + }; + + const forwardWithRetry = (proxy as unknown as { forwardWithRetry: (req: unknown, headers: unknown) => Promise }).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 }).forwardToUpstream = async () => { + attempts++; + throw new Error('Upstream error: 429 Too Many Requests'); + }; + + const forwardWithRetry = (proxy as unknown as { forwardWithRetry: (req: unknown, headers: unknown) => Promise }).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 }).forwardToUpstream = async () => { + attempts++; + throw new Error('Upstream error: 500 Internal Server Error'); + }; + + const forwardWithRetry = (proxy as unknown as { forwardWithRetry: (req: unknown, headers: unknown) => Promise }).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); + }); + }); +});