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
1039 lines
35 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|