mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 22:16:41 +00:00
350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
/**
|
|
* GLMT Retry Logic Unit Tests
|
|
*
|
|
* Tests for exponential backoff retry behavior on 429 rate limit errors
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, setDefaultTimeout } from 'bun:test';
|
|
|
|
// Increase timeout for CI - dynamic imports and proxy creation are slow on CI runners
|
|
setDefaultTimeout(30000);
|
|
|
|
// 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);
|
|
});
|
|
});
|
|
});
|