Files
wehub-resource-sync 0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

1039 lines
35 KiB
TypeScript

import { EventEmitter } from 'events';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ApiProvider } from '../../src/types/providers';
// Track constructor calls and instances
const mockProviderRateLimitStateConstructor = vi.fn();
const mockGetRateLimitKey = vi.fn();
const mockGetEnvInt = vi.fn();
const mockGetEnvBool = vi.fn();
let mockStateToReturn: any = null;
const mockStateQueue: any[] = [];
// Mock dependencies before imports
vi.mock('../../src/scheduler/providerRateLimitState', () => ({
ProviderRateLimitState: class extends EventEmitter {
executeWithRetry: any;
getMetrics: any;
getQueueDepth: any;
dispose: any;
constructor(options: any) {
super();
mockProviderRateLimitStateConstructor(options);
// Check if we have a queued mock state
const queuedState = mockStateQueue.shift();
if (queuedState) {
this.executeWithRetry = queuedState.executeWithRetry;
this.getMetrics = queuedState.getMetrics;
this.getQueueDepth = queuedState.getQueueDepth || vi.fn().mockReturnValue(0);
this.dispose = queuedState.dispose;
} else if (mockStateToReturn) {
// If we have a specific mock state to return, copy its methods
this.executeWithRetry = mockStateToReturn.executeWithRetry;
this.getMetrics = mockStateToReturn.getMetrics;
this.getQueueDepth = mockStateToReturn.getQueueDepth || vi.fn().mockReturnValue(0);
this.dispose = mockStateToReturn.dispose;
} else {
// Default mocks
this.executeWithRetry = vi.fn().mockResolvedValue('result');
this.getMetrics = vi.fn().mockReturnValue({
rateLimitKey: 'test-provider',
activeRequests: 0,
maxConcurrency: 10,
queueDepth: 0,
totalRequests: 0,
completedRequests: 0,
failedRequests: 0,
rateLimitHits: 0,
retriedRequests: 0,
avgLatencyMs: 0,
p50LatencyMs: 0,
p99LatencyMs: 0,
});
this.getQueueDepth = vi.fn().mockReturnValue(0);
this.dispose = vi.fn();
}
}
},
}));
vi.mock('../../src/scheduler/rateLimitKey', () => ({
getRateLimitKey: mockGetRateLimitKey,
}));
vi.mock('../../src/envars', () => ({
getEnvInt: mockGetEnvInt,
getEnvBool: mockGetEnvBool,
}));
vi.mock('../../src/logger', () => ({
__esModule: true,
default: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
logRequestResponse: vi.fn(),
}));
// Import after mocks are set up
const { RateLimitRegistry, createRateLimitRegistry } = await import(
'../../src/scheduler/rateLimitRegistry'
);
describe('RateLimitRegistry', () => {
let mockProvider: ApiProvider;
let mockState: any;
beforeEach(() => {
// Reset mocks with mockReset() to clear both call history AND implementations
// This ensures test isolation when tests use mockReturnValueOnce
mockProviderRateLimitStateConstructor.mockReset();
mockGetRateLimitKey.mockReset();
mockGetEnvInt.mockReset();
mockGetEnvBool.mockReset();
// Clear the queue
mockStateQueue.length = 0;
// Setup mock provider
mockProvider = {
id: () => 'test-provider',
config: {},
callApi: vi.fn(),
} as unknown as ApiProvider;
// Setup mock state
mockState = {
executeWithRetry: vi.fn().mockResolvedValue('result'),
getMetrics: vi.fn().mockReturnValue({
rateLimitKey: 'test-provider',
activeRequests: 0,
maxConcurrency: 10,
queueDepth: 0,
totalRequests: 0,
completedRequests: 0,
failedRequests: 0,
rateLimitHits: 0,
retriedRequests: 0,
avgLatencyMs: 0,
p50LatencyMs: 0,
p99LatencyMs: 0,
}),
dispose: vi.fn(),
emit: vi.fn(),
on: vi.fn(),
listenerCount: vi.fn().mockReturnValue(1),
};
// Set this as the mock to be returned by the constructor
mockStateToReturn = mockState;
// Mock getRateLimitKey
mockGetRateLimitKey.mockReturnValue('test-provider');
// Mock envars with defaults
mockGetEnvInt.mockReturnValue(1);
mockGetEnvBool.mockReturnValue(false);
});
afterEach(() => {
vi.resetAllMocks();
});
describe('createRateLimitRegistry - factory function', () => {
it('should create a new RateLimitRegistry instance', () => {
const registry = createRateLimitRegistry({ maxConcurrency: 10 });
expect(registry).toBeInstanceOf(RateLimitRegistry);
});
it('should pass options to constructor', () => {
const registry = createRateLimitRegistry({ maxConcurrency: 20, minConcurrency: 2 });
expect(registry).toBeInstanceOf(RateLimitRegistry);
});
});
describe('Constructor - options handling', () => {
it('should initialize with maxConcurrency from options', () => {
const registry = new RateLimitRegistry({ maxConcurrency: 15 });
expect(registry).toBeDefined();
});
it('should use minConcurrency from options if provided', () => {
mockGetEnvInt.mockReturnValue(1);
const registry = new RateLimitRegistry({ maxConcurrency: 10, minConcurrency: 3 });
// Execute to trigger state creation
const callFn = vi.fn().mockResolvedValue('result');
registry.execute(mockProvider, callFn);
// Check ProviderRateLimitState was created with correct minConcurrency
// queueTimeoutMs is 1 because mockGetEnvInt returns 1 for all calls
expect(mockProviderRateLimitStateConstructor).toHaveBeenCalledWith({
rateLimitKey: 'test-provider',
maxConcurrency: 10,
minConcurrency: 3,
queueTimeoutMs: 1,
});
});
it('should use default minConcurrency from env when not provided', () => {
mockGetEnvInt.mockReturnValue(2);
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
// Execute to trigger state creation
const callFn = vi.fn().mockResolvedValue('result');
registry.execute(mockProvider, callFn);
expect(mockGetEnvInt).toHaveBeenCalledWith('PROMPTFOO_MIN_CONCURRENCY', 1);
expect(mockProviderRateLimitStateConstructor).toHaveBeenCalledWith({
rateLimitKey: 'test-provider',
maxConcurrency: 10,
minConcurrency: 2,
queueTimeoutMs: 2,
});
});
it('should enable scheduler by default', async () => {
mockGetEnvBool.mockReturnValue(false);
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const callFn = vi.fn().mockResolvedValue('result');
await registry.execute(mockProvider, callFn);
// Should create state and use scheduler
expect(mockProviderRateLimitStateConstructor).toHaveBeenCalled();
});
it('should disable scheduler when env var is true', async () => {
mockGetEnvBool.mockReturnValue(true);
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const callFn = vi.fn().mockResolvedValue('result');
const result = await registry.execute(mockProvider, callFn);
expect(mockGetEnvBool).toHaveBeenCalledWith('PROMPTFOO_DISABLE_ADAPTIVE_SCHEDULER', false);
expect(callFn).toHaveBeenCalled();
expect(mockProviderRateLimitStateConstructor).not.toHaveBeenCalled();
expect(result).toBe('result');
});
});
describe('execute - calls function directly when disabled', () => {
it('should bypass rate limiting when disabled', async () => {
mockGetEnvBool.mockReturnValue(true);
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const callFn = vi.fn().mockResolvedValue('direct-result');
const result = await registry.execute(mockProvider, callFn);
expect(result).toBe('direct-result');
expect(callFn).toHaveBeenCalledTimes(1);
expect(mockProviderRateLimitStateConstructor).not.toHaveBeenCalled();
});
it('should propagate errors when disabled', async () => {
mockGetEnvBool.mockReturnValue(true);
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const error = new Error('Test error');
const callFn = vi.fn().mockRejectedValue(error);
await expect(registry.execute(mockProvider, callFn)).rejects.toThrow('Test error');
});
});
describe('execute - wraps calls through ProviderRateLimitState', () => {
it('should call executeWithRetry on state', async () => {
mockState.executeWithRetry.mockResolvedValue('state-result');
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const callFn = vi.fn().mockResolvedValue('call-result');
const result = await registry.execute(mockProvider, callFn);
expect(mockState.executeWithRetry).toHaveBeenCalledWith(
expect.stringContaining('test-provider-'),
callFn,
{
getHeaders: undefined,
isRateLimited: undefined,
getRetryAfter: undefined,
maxRetriesOverride: undefined,
},
);
expect(result).toBe('state-result');
});
it('should pass options to executeWithRetry', async () => {
mockState.executeWithRetry.mockResolvedValue('result');
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const callFn = vi.fn();
const getHeaders = vi.fn();
const isRateLimited = vi.fn();
const getRetryAfter = vi.fn();
await registry.execute(mockProvider, callFn, {
getHeaders,
isRateLimited,
getRetryAfter,
});
expect(mockState.executeWithRetry).toHaveBeenCalledWith(expect.any(String), callFn, {
getHeaders,
isRateLimited,
getRetryAfter,
maxRetriesOverride: undefined,
});
});
it.each([
[0, 0],
[5, 5],
['2', 2],
])('should forward provider config.maxRetries %p as maxRetriesOverride %p', async (input, expected) => {
mockState.executeWithRetry.mockResolvedValue('result');
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const provider = {
...mockProvider,
config: { ...mockProvider.config, maxRetries: input },
} as ApiProvider;
await registry.execute(provider, vi.fn());
expect(mockState.executeWithRetry).toHaveBeenLastCalledWith(
expect.any(String),
expect.any(Function),
expect.objectContaining({ maxRetriesOverride: expected }),
);
});
it.each([
['negative number', -1],
['non-integer number', 2.5],
['non-integer string', '2.5'],
// Digit strings that overflow to Infinity / lose precision would cause
// unbounded retry loops if accepted — reject them.
['unsafe-integer string', '1' + '0'.repeat(400)],
['above MAX_SAFE_INTEGER', String(Number.MAX_SAFE_INTEGER) + '0'],
])('should warn and forward maxRetriesOverride=undefined for invalid %s (%p)', async (_label, input) => {
mockState.executeWithRetry.mockResolvedValue('result');
const logger = (await import('../../src/logger')).default;
const warnSpy = vi.mocked(logger.warn);
warnSpy.mockClear();
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const provider = {
...mockProvider,
config: { ...mockProvider.config, maxRetries: input },
} as ApiProvider;
await registry.execute(provider, vi.fn());
expect(warnSpy).toHaveBeenCalledWith(
'[RateLimit] Ignoring invalid provider.config.maxRetries; expected a non-negative integer.',
expect.objectContaining({
maxRetries: input,
providerId: 'test-provider',
}),
);
expect(mockState.executeWithRetry).toHaveBeenLastCalledWith(
expect.any(String),
expect.any(Function),
expect.objectContaining({ maxRetriesOverride: undefined }),
);
});
it('should not warn when provider does not set maxRetries', async () => {
mockState.executeWithRetry.mockResolvedValue('result');
const logger = (await import('../../src/logger')).default;
const warnSpy = vi.mocked(logger.warn);
warnSpy.mockClear();
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
await registry.execute(mockProvider, vi.fn());
expect(warnSpy).not.toHaveBeenCalled();
});
it('should sanitize URL provider ids in invalid maxRetries warnings', async () => {
mockState.executeWithRetry.mockResolvedValue('result');
const logger = (await import('../../src/logger')).default;
const warnSpy = vi.mocked(logger.warn);
warnSpy.mockClear();
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const provider = {
...mockProvider,
id: () => 'https://example.com/api?api_key=secret&model=test',
config: { ...mockProvider.config, maxRetries: -1 },
} as ApiProvider;
await registry.execute(provider, vi.fn());
const [, context] = warnSpy.mock.calls[0];
expect(context).toEqual(
expect.objectContaining({
maxRetries: -1,
providerId: expect.stringContaining('api_key=%5BREDACTED%5D'),
}),
);
expect((context as { providerId: string }).providerId).not.toContain('secret');
});
it('should generate unique request IDs', async () => {
mockState.executeWithRetry.mockResolvedValue('result');
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const callFn = vi.fn();
const requestIds: string[] = [];
// Capture request IDs from executeWithRetry calls
mockState.executeWithRetry.mockImplementation(async (requestId: string) => {
requestIds.push(requestId);
return 'result';
});
await registry.execute(mockProvider, callFn);
await registry.execute(mockProvider, callFn);
expect(requestIds).toHaveLength(2);
expect(requestIds[0]).not.toBe(requestIds[1]);
expect(requestIds[0]).toMatch(/^test-provider-\d+-[a-z0-9]+$/);
});
});
describe('execute - creates new state for new providers', () => {
it('should create state on first call', async () => {
mockState.executeWithRetry.mockResolvedValue('result');
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const callFn = vi.fn();
await registry.execute(mockProvider, callFn);
expect(mockProviderRateLimitStateConstructor).toHaveBeenCalledTimes(1);
expect(mockProviderRateLimitStateConstructor).toHaveBeenCalledWith({
rateLimitKey: 'test-provider',
maxConcurrency: 10,
minConcurrency: 1,
queueTimeoutMs: 1,
});
});
it('should setup event forwarding for new state', async () => {
mockState.executeWithRetry.mockResolvedValue('result');
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const callFn = vi.fn();
await registry.execute(mockProvider, callFn);
// Verify state was created with event forwarding capability
// Note: Full event forwarding is tested in the "Event forwarding" describe block
expect(mockProviderRateLimitStateConstructor).toHaveBeenCalledTimes(1);
});
});
describe('execute - reuses state for same provider', () => {
it('should reuse existing state for same provider', async () => {
mockState.executeWithRetry.mockResolvedValue('result');
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const callFn = vi.fn();
await registry.execute(mockProvider, callFn);
await registry.execute(mockProvider, callFn);
await registry.execute(mockProvider, callFn);
// State should only be created once
expect(mockProviderRateLimitStateConstructor).toHaveBeenCalledTimes(1);
expect(mockState.executeWithRetry).toHaveBeenCalledTimes(3);
});
});
describe('execute - different API keys get different states (via rateLimitKey)', () => {
it('should create separate states for different rate limit keys', async () => {
mockState.executeWithRetry.mockResolvedValue('result');
const provider1 = { id: () => 'provider-1', config: { apiKey: 'key1' } } as ApiProvider;
const provider2 = { id: () => 'provider-2', config: { apiKey: 'key2' } } as ApiProvider;
// Mock different rate limit keys
mockGetRateLimitKey
.mockReturnValueOnce('provider-1[abc123]')
.mockReturnValueOnce('provider-2[def456]');
// Create mock states for each provider
const state1 = {
executeWithRetry: vi.fn().mockResolvedValue('result1'),
getMetrics: vi.fn().mockReturnValue({ queueDepth: 0 }),
dispose: vi.fn(),
};
const state2 = {
executeWithRetry: vi.fn().mockResolvedValue('result2'),
getMetrics: vi.fn().mockReturnValue({ queueDepth: 0 }),
dispose: vi.fn(),
};
// Queue the states to be returned by constructor
mockStateQueue.push(state1, state2);
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const callFn = vi.fn();
await registry.execute(provider1, callFn);
await registry.execute(provider2, callFn);
// Two different states should be created
expect(mockProviderRateLimitStateConstructor).toHaveBeenCalledTimes(2);
expect(mockProviderRateLimitStateConstructor).toHaveBeenNthCalledWith(1, {
rateLimitKey: 'provider-1[abc123]',
maxConcurrency: 10,
minConcurrency: 1,
queueTimeoutMs: 1,
});
expect(mockProviderRateLimitStateConstructor).toHaveBeenNthCalledWith(2, {
rateLimitKey: 'provider-2[def456]',
maxConcurrency: 10,
minConcurrency: 1,
queueTimeoutMs: 1,
});
});
});
describe('Event forwarding', () => {
it('should emit request:started event', async () => {
mockState.executeWithRetry.mockResolvedValue('result');
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const startedListener = vi.fn();
registry.on('request:started', startedListener);
const callFn = vi.fn();
await registry.execute(mockProvider, callFn);
expect(startedListener).toHaveBeenCalledWith({
rateLimitKey: 'test-provider',
requestId: expect.stringContaining('test-provider-'),
queueDepth: 0,
});
});
it('should emit request:completed event on success', async () => {
mockState.executeWithRetry.mockResolvedValue('result');
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const completedListener = vi.fn();
registry.on('request:completed', completedListener);
const callFn = vi.fn();
await registry.execute(mockProvider, callFn);
expect(completedListener).toHaveBeenCalledWith({
rateLimitKey: 'test-provider',
requestId: expect.stringContaining('test-provider-'),
});
});
it('should emit request:failed event on error', async () => {
const error = new Error('Test failure');
mockState.executeWithRetry.mockRejectedValue(error);
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const failedListener = vi.fn();
registry.on('request:failed', failedListener);
const callFn = vi.fn();
await expect(registry.execute(mockProvider, callFn)).rejects.toThrow('Test failure');
expect(failedListener).toHaveBeenCalledWith({
rateLimitKey: 'test-provider',
requestId: expect.stringContaining('test-provider-'),
error: 'Error: Test failure',
});
});
it('should forward ratelimit:hit events from state', async () => {
mockState.executeWithRetry.mockImplementation(async function (this: any) {
this.emit('ratelimit:hit', {
rateLimitKey: 'test-provider',
retryAfterMs: 1000,
resetAt: Date.now() + 1000,
concurrencyChange: { changed: true, previous: 10, current: 5, reason: 'ratelimit' },
});
return 'result';
});
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const hitListener = vi.fn();
registry.on('ratelimit:hit', hitListener);
const callFn = vi.fn();
await registry.execute(mockProvider, callFn);
expect(hitListener).toHaveBeenCalledWith({
rateLimitKey: 'test-provider',
retryAfterMs: 1000,
resetAt: expect.any(Number),
concurrencyChange: { changed: true, previous: 10, current: 5, reason: 'ratelimit' },
});
});
it('should forward concurrency:decreased events from state', async () => {
mockState.executeWithRetry.mockImplementation(async function (this: any) {
this.emit('concurrency:decreased', {
rateLimitKey: 'test-provider',
changed: true,
previous: 10,
current: 5,
reason: 'ratelimit',
});
return 'result';
});
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const decreasedListener = vi.fn();
registry.on('concurrency:decreased', decreasedListener);
const callFn = vi.fn();
await registry.execute(mockProvider, callFn);
expect(decreasedListener).toHaveBeenCalledWith({
rateLimitKey: 'test-provider',
changed: true,
previous: 10,
current: 5,
reason: 'ratelimit',
});
});
it('should forward concurrency:increased events from state', async () => {
mockState.executeWithRetry.mockImplementation(async function (this: any) {
this.emit('concurrency:increased', {
rateLimitKey: 'test-provider',
changed: true,
previous: 5,
current: 8,
reason: 'recovery',
});
return 'result';
});
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const increasedListener = vi.fn();
registry.on('concurrency:increased', increasedListener);
const callFn = vi.fn();
await registry.execute(mockProvider, callFn);
expect(increasedListener).toHaveBeenCalledWith({
rateLimitKey: 'test-provider',
changed: true,
previous: 5,
current: 8,
reason: 'recovery',
});
});
it('should forward ratelimit:warning events from state', async () => {
mockState.executeWithRetry.mockImplementation(async function (this: any) {
this.emit('ratelimit:warning', {
rateLimitKey: 'test-provider',
requestRatio: 0.05,
tokenRatio: 0.08,
});
return 'result';
});
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const warningListener = vi.fn();
registry.on('ratelimit:warning', warningListener);
const callFn = vi.fn();
await registry.execute(mockProvider, callFn);
expect(warningListener).toHaveBeenCalledWith({
rateLimitKey: 'test-provider',
requestRatio: 0.05,
tokenRatio: 0.08,
});
});
it('should forward ratelimit:learned events from state', async () => {
mockState.executeWithRetry.mockImplementation(async function (this: any) {
this.emit('ratelimit:learned', {
rateLimitKey: 'test-provider',
requestLimit: 100,
tokenLimit: 50000,
});
return 'result';
});
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const learnedListener = vi.fn();
registry.on('ratelimit:learned', learnedListener);
const callFn = vi.fn();
await registry.execute(mockProvider, callFn);
expect(learnedListener).toHaveBeenCalledWith({
rateLimitKey: 'test-provider',
requestLimit: 100,
tokenLimit: 50000,
});
});
it('should forward request:retrying events from state', async () => {
mockState.executeWithRetry.mockImplementation(async function (this: any) {
this.emit('request:retrying', {
rateLimitKey: 'test-provider',
attempt: 1,
delayMs: 2000,
reason: 'ratelimit',
});
return 'result';
});
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const retryingListener = vi.fn();
registry.on('request:retrying', retryingListener);
const callFn = vi.fn();
await registry.execute(mockProvider, callFn);
expect(retryingListener).toHaveBeenCalledWith({
rateLimitKey: 'test-provider',
attempt: 1,
delayMs: 2000,
reason: 'ratelimit',
});
});
});
describe('getMetrics - returns metrics for all providers', () => {
it('should return empty metrics when no providers have been used', () => {
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const metrics = registry.getMetrics();
expect(metrics).toEqual({});
});
it('should return metrics for single provider', async () => {
mockState.executeWithRetry.mockResolvedValue('result');
vi.mocked(mockState.getMetrics).mockReturnValue({
rateLimitKey: 'test-provider',
activeRequests: 2,
maxConcurrency: 10,
queueDepth: 1,
totalRequests: 5,
completedRequests: 3,
failedRequests: 0,
rateLimitHits: 1,
retriedRequests: 2,
avgLatencyMs: 150,
p50LatencyMs: 140,
p99LatencyMs: 200,
});
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const callFn = vi.fn();
await registry.execute(mockProvider, callFn);
const metrics = registry.getMetrics();
expect(metrics).toEqual({
'test-provider': {
rateLimitKey: 'test-provider',
activeRequests: 2,
maxConcurrency: 10,
queueDepth: 1,
totalRequests: 5,
completedRequests: 3,
failedRequests: 0,
rateLimitHits: 1,
retriedRequests: 2,
avgLatencyMs: 150,
p50LatencyMs: 140,
p99LatencyMs: 200,
},
});
});
it('should return metrics for multiple providers', async () => {
const provider1 = { id: () => 'provider-1', config: {} } as ApiProvider;
const provider2 = { id: () => 'provider-2', config: {} } as ApiProvider;
// Mock different rate limit keys
mockGetRateLimitKey
.mockReturnValueOnce('provider-1')
.mockReturnValueOnce('provider-2')
.mockReturnValue('provider-1'); // For getMetrics calls
// Create separate mock states
const state1 = {
executeWithRetry: vi.fn().mockResolvedValue('result1'),
getMetrics: vi.fn().mockReturnValue({
rateLimitKey: 'provider-1',
activeRequests: 1,
maxConcurrency: 10,
queueDepth: 0,
totalRequests: 10,
completedRequests: 10,
failedRequests: 0,
rateLimitHits: 0,
retriedRequests: 0,
avgLatencyMs: 100,
p50LatencyMs: 95,
p99LatencyMs: 150,
}),
dispose: vi.fn(),
};
const state2 = {
executeWithRetry: vi.fn().mockResolvedValue('result2'),
getMetrics: vi.fn().mockReturnValue({
rateLimitKey: 'provider-2',
activeRequests: 3,
maxConcurrency: 5,
queueDepth: 2,
totalRequests: 20,
completedRequests: 15,
failedRequests: 2,
rateLimitHits: 3,
retriedRequests: 5,
avgLatencyMs: 200,
p50LatencyMs: 180,
p99LatencyMs: 300,
}),
dispose: vi.fn(),
};
mockStateQueue.push(state1, state2);
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const callFn = vi.fn();
await registry.execute(provider1, callFn);
await registry.execute(provider2, callFn);
const metrics = registry.getMetrics();
expect(Object.keys(metrics)).toHaveLength(2);
expect(metrics['provider-1']).toBeDefined();
expect(metrics['provider-2']).toBeDefined();
expect(metrics['provider-1'].totalRequests).toBe(10);
expect(metrics['provider-2'].totalRequests).toBe(20);
});
});
describe('dispose - cleans up all states', () => {
it('should dispose all states', async () => {
mockState.executeWithRetry.mockResolvedValue('result');
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const callFn = vi.fn();
await registry.execute(mockProvider, callFn);
registry.dispose();
expect(mockState.dispose).toHaveBeenCalledTimes(1);
});
it('should clear all states map', async () => {
mockState.executeWithRetry.mockResolvedValue('result');
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const callFn = vi.fn();
await registry.execute(mockProvider, callFn);
expect(registry.getMetrics()).not.toEqual({});
registry.dispose();
expect(registry.getMetrics()).toEqual({});
});
it('should remove all event listeners', async () => {
mockState.executeWithRetry.mockResolvedValue('result');
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const listener = vi.fn();
registry.on('request:started', listener);
registry.on('request:completed', listener);
const callFn = vi.fn();
await registry.execute(mockProvider, callFn);
expect(registry.listenerCount('request:started')).toBe(1);
expect(registry.listenerCount('request:completed')).toBe(1);
registry.dispose();
expect(registry.listenerCount('request:started')).toBe(0);
expect(registry.listenerCount('request:completed')).toBe(0);
});
it('should dispose multiple states', async () => {
const provider1 = { id: () => 'provider-1', config: {} } as ApiProvider;
const provider2 = { id: () => 'provider-2', config: {} } as ApiProvider;
mockGetRateLimitKey.mockReturnValueOnce('provider-1').mockReturnValueOnce('provider-2');
const state1 = new EventEmitter() as any;
state1.executeWithRetry = vi.fn().mockResolvedValue('result');
state1.getMetrics = vi.fn().mockReturnValue({ queueDepth: 0 });
state1.dispose = vi.fn();
const state2 = new EventEmitter() as any;
state2.executeWithRetry = vi.fn().mockResolvedValue('result');
state2.getMetrics = vi.fn().mockReturnValue({ queueDepth: 0 });
state2.dispose = vi.fn();
mockStateQueue.push(state1, state2);
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const callFn = vi.fn();
await registry.execute(provider1, callFn);
await registry.execute(provider2, callFn);
registry.dispose();
expect(state1.dispose).toHaveBeenCalledTimes(1);
expect(state2.dispose).toHaveBeenCalledTimes(1);
});
});
describe('Error handling - propagates errors from callFn', () => {
it('should propagate errors from executeWithRetry', async () => {
const error = new Error('Execution failed');
mockState.executeWithRetry.mockRejectedValue(error);
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const callFn = vi.fn();
await expect(registry.execute(mockProvider, callFn)).rejects.toThrow('Execution failed');
});
it('should emit failed event before throwing', async () => {
const error = new Error('Test error');
mockState.executeWithRetry.mockRejectedValue(error);
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const failedListener = vi.fn();
registry.on('request:failed', failedListener);
const callFn = vi.fn();
try {
await registry.execute(mockProvider, callFn);
} catch {
// Expected
}
expect(failedListener).toHaveBeenCalledWith({
rateLimitKey: 'test-provider',
requestId: expect.any(String),
error: 'Error: Test error',
});
});
it('should not emit completed event on error', async () => {
const error = new Error('Test error');
mockState.executeWithRetry.mockRejectedValue(error);
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const completedListener = vi.fn();
registry.on('request:completed', completedListener);
const callFn = vi.fn();
try {
await registry.execute(mockProvider, callFn);
} catch {
// Expected
}
expect(completedListener).not.toHaveBeenCalled();
});
});
describe('Multi-provider isolation - providers do not interfere', () => {
it('should isolate execution between different providers', async () => {
const provider1 = { id: () => 'provider-1', config: {} } as ApiProvider;
const provider2 = { id: () => 'provider-2', config: {} } as ApiProvider;
mockGetRateLimitKey
.mockReturnValueOnce('provider-1')
.mockReturnValueOnce('provider-2')
.mockReturnValueOnce('provider-1')
.mockReturnValueOnce('provider-2');
const state1 = new EventEmitter() as any;
state1.executeWithRetry = vi.fn().mockResolvedValue('result1');
state1.getMetrics = vi.fn().mockReturnValue({ queueDepth: 0 });
state1.dispose = vi.fn();
const state2 = new EventEmitter() as any;
state2.executeWithRetry = vi.fn().mockResolvedValue('result2');
state2.getMetrics = vi.fn().mockReturnValue({ queueDepth: 0 });
state2.dispose = vi.fn();
mockStateQueue.push(state1, state2);
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const callFn1 = vi.fn();
const callFn2 = vi.fn();
await registry.execute(provider1, callFn1);
await registry.execute(provider2, callFn2);
await registry.execute(provider1, callFn1);
await registry.execute(provider2, callFn2);
// Each state should be called twice
expect(state1.executeWithRetry).toHaveBeenCalledTimes(2);
expect(state2.executeWithRetry).toHaveBeenCalledTimes(2);
});
it('should handle errors in one provider without affecting others', async () => {
const provider1 = { id: () => 'provider-1', config: {} } as ApiProvider;
const provider2 = { id: () => 'provider-2', config: {} } as ApiProvider;
mockGetRateLimitKey
.mockReturnValueOnce('provider-1')
.mockReturnValueOnce('provider-2')
.mockReturnValueOnce('provider-1');
const state1 = new EventEmitter() as any;
state1.executeWithRetry = vi.fn().mockRejectedValue(new Error('Provider 1 error'));
state1.getMetrics = vi.fn().mockReturnValue({ queueDepth: 0 });
state1.dispose = vi.fn();
const state2 = new EventEmitter() as any;
state2.executeWithRetry = vi.fn().mockResolvedValue('result2');
state2.getMetrics = vi.fn().mockReturnValue({ queueDepth: 0 });
state2.dispose = vi.fn();
mockStateQueue.push(state1, state2);
const registry = new RateLimitRegistry({ maxConcurrency: 10 });
const callFn = vi.fn();
// Provider 1 fails
await expect(registry.execute(provider1, callFn)).rejects.toThrow('Provider 1 error');
// Provider 2 succeeds
const result2 = await registry.execute(provider2, callFn);
expect(result2).toBe('result2');
// Provider 1 still fails
await expect(registry.execute(provider1, callFn)).rejects.toThrow('Provider 1 error');
});
});
});