Files
promptfoo--promptfoo/test/scheduler/providerRateLimitState.test.ts
wehub-resource-sync 0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
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) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

705 lines
22 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ProviderRateLimitState } from '../../src/scheduler/providerRateLimitState';
// Fast retry policy for tests - minimal delays
const FAST_RETRY_POLICY = {
maxRetries: 3,
baseDelayMs: 1, // 1ms instead of 1000ms
maxDelayMs: 10, // 10ms instead of 60000ms
jitterFactor: 0,
};
describe('ProviderRateLimitState', () => {
let state: ProviderRateLimitState;
beforeEach(() => {
vi.useFakeTimers();
state = new ProviderRateLimitState({
rateLimitKey: 'test-provider',
maxConcurrency: 5,
minConcurrency: 1,
retryPolicy: FAST_RETRY_POLICY,
});
});
afterEach(() => {
state.dispose();
vi.useRealTimers();
});
describe('Constructor', () => {
it('should initialize with provided options', () => {
const metrics = state.getMetrics();
expect(metrics.rateLimitKey).toBe('test-provider');
expect(metrics.maxConcurrency).toBe(5);
});
it('should accept custom retry policy', () => {
const customState = new ProviderRateLimitState({
rateLimitKey: 'custom-provider',
maxConcurrency: 10,
minConcurrency: 2,
retryPolicy: {
maxRetries: 5,
baseDelayMs: 2000,
maxDelayMs: 30000,
jitterFactor: 0.1,
},
});
expect(customState.rateLimitKey).toBe('custom-provider');
customState.dispose();
});
});
describe('executeWithRetry - success path', () => {
it('should execute function and return result', async () => {
const result = await state.executeWithRetry('req-1', async () => 'success', {});
expect(result).toBe('success');
});
it('should increment completed requests on success', async () => {
await state.executeWithRetry('req-1', async () => 'success', {});
const metrics = state.getMetrics();
expect(metrics.completedRequests).toBe(1);
expect(metrics.failedRequests).toBe(0);
});
it('should increment total requests', async () => {
await state.executeWithRetry('req-1', async () => 'success', {});
await state.executeWithRetry('req-2', async () => 'success', {});
const metrics = state.getMetrics();
expect(metrics.totalRequests).toBe(2);
});
});
describe('executeWithRetry - error handling', () => {
it('should propagate errors', async () => {
await expect(
state.executeWithRetry(
'req-1',
async () => {
throw new Error('Test error');
},
{},
),
).rejects.toThrow('Test error');
});
it('should increment failed requests on error', async () => {
try {
await state.executeWithRetry(
'req-1',
async () => {
throw new Error('Test error');
},
{},
);
} catch {}
const metrics = state.getMetrics();
expect(metrics.failedRequests).toBe(1);
expect(metrics.completedRequests).toBe(0);
});
});
describe('executeWithRetry - rate limit detection', () => {
let noRetryState: ProviderRateLimitState;
beforeEach(() => {
// Use no-retry policy for rate limit detection tests
noRetryState = new ProviderRateLimitState({
rateLimitKey: 'test-provider-no-retry',
maxConcurrency: 5,
minConcurrency: 1,
retryPolicy: { maxRetries: 0, baseDelayMs: 1, maxDelayMs: 1, jitterFactor: 0 },
});
});
afterEach(() => {
noRetryState.dispose();
});
it('should detect rate limit via isRateLimited callback', async () => {
const events: any[] = [];
noRetryState.on('ratelimit:hit', (data) => events.push(data));
try {
await noRetryState.executeWithRetry('req-1', async () => ({ status: 429 }), {
isRateLimited: (result) => result?.status === 429,
});
} catch {}
expect(events.length).toBeGreaterThan(0);
});
it('should detect rate limit error by message', async () => {
const events: any[] = [];
noRetryState.on('ratelimit:hit', (data) => events.push(data));
try {
await noRetryState.executeWithRetry(
'req-1',
async () => {
throw new Error('Rate limit exceeded');
},
{},
);
} catch {}
expect(events.length).toBeGreaterThan(0);
});
it('should detect 429 error by message', async () => {
const events: any[] = [];
noRetryState.on('ratelimit:hit', (data) => events.push(data));
try {
await noRetryState.executeWithRetry(
'req-1',
async () => {
throw new Error('HTTP 429: Too Many Requests');
},
{},
);
} catch {}
expect(events.length).toBeGreaterThan(0);
});
it('should detect too many requests error', async () => {
const events: any[] = [];
noRetryState.on('ratelimit:hit', (data) => events.push(data));
try {
await noRetryState.executeWithRetry(
'req-1',
async () => {
throw new Error('Too many requests');
},
{},
);
} catch {}
expect(events.length).toBeGreaterThan(0);
});
it('should handle error with undefined message gracefully', async () => {
// Test that isRateLimitError doesn't crash when error.message is undefined
const error = new Error('placeholder');
(error as { message: string | undefined }).message = undefined;
// Should not throw - just not detect it as a rate limit
await expect(
noRetryState.executeWithRetry(
'req-1',
async () => {
throw error;
},
{},
),
).rejects.toBeDefined();
// The error should be counted as failed, not as a rate limit
const metrics = noRetryState.getMetrics();
expect(metrics.failedRequests).toBe(1);
expect(metrics.rateLimitHits).toBe(0);
});
it('back-compat: substring matcher detects HttpRateLimitError (rate_limit kind)', async () => {
// The transport-layer HttpRateLimitError is unknown to the scheduler,
// so the scheduler relies on its substring matcher to detect rate-limit
// hits. The HttpRateLimitError contract requires the rendered message
// to contain `429` and a rate-limit token; verify the scheduler still
// counts these as ratelimit:hit events.
const { HttpRateLimitError } = await import('../../src/util/fetch/errors');
const events: any[] = [];
noRetryState.on('ratelimit:hit', (data) => events.push(data));
try {
await noRetryState.executeWithRetry(
'req-1',
async () => {
throw new HttpRateLimitError({
status: 429,
code: 'rate_limit_exceeded',
retryAfterMs: 5000,
});
},
{},
);
} catch {}
expect(events.length).toBeGreaterThan(0);
const metrics = noRetryState.getMetrics();
expect(metrics.rateLimitHits).toBeGreaterThan(0);
});
it('back-compat: substring matcher detects HttpRateLimitError (quota kind)', async () => {
const { HttpRateLimitError } = await import('../../src/util/fetch/errors');
const events: any[] = [];
noRetryState.on('ratelimit:hit', (data) => events.push(data));
try {
await noRetryState.executeWithRetry(
'req-1',
async () => {
throw new HttpRateLimitError({
status: 429,
code: 'insufficient_quota',
});
},
{},
);
} catch {}
expect(events.length).toBeGreaterThan(0);
const metrics = noRetryState.getMetrics();
expect(metrics.rateLimitHits).toBeGreaterThan(0);
});
it('result-path: kind=quota in result.metadata short-circuits retry', async () => {
// The PR's transport-layer fail-fast is undermined when a provider
// catches HttpRateLimitError and folds it into ProviderResponse.error
// (the standard pattern across most providers). The default
// isRateLimited callback for ProviderResponse must honor the
// structured `metadata.rateLimitKind: 'quota'` signal so the scheduler
// doesn't retry hard quotas through the result path.
const { isProviderResponseRateLimited } = await import('../../src/scheduler/types');
let callCount = 0;
const result = await state.executeWithRetry(
'req-quota',
async () => {
callCount++;
return {
error: 'Quota exceeded: HTTP 429 Too Many Requests (code: insufficient_quota).',
metadata: { rateLimitKind: 'quota' as const },
};
},
{
isRateLimited: isProviderResponseRateLimited,
},
);
// With kind=quota, the classifier returns false, so the scheduler
// sees a "successful" result and returns it without retrying.
expect(callCount).toBe(1);
expect((result as { error?: string }).error).toContain('Quota exceeded');
});
it('result-path: "Quota exceeded:" prefix short-circuits retry even without metadata', async () => {
// String-fallback path for providers that don't populate metadata but
// still emit the canonical formatRateLimitErrorMessage prefix.
const { isProviderResponseRateLimited } = await import('../../src/scheduler/types');
let callCount = 0;
const result = await state.executeWithRetry(
'req-quota-nometa',
async () => {
callCount++;
return {
error: 'Quota exceeded: HTTP 429 Too Many Requests (code: insufficient_quota).',
};
},
{
isRateLimited: isProviderResponseRateLimited,
},
);
expect(callCount).toBe(1);
expect((result as { error?: string }).error).toContain('Quota exceeded');
});
it('result-path: kind=rate_limit still triggers retry', async () => {
// Symmetric verification: per-window rate limits must still retry.
const { isProviderResponseRateLimited } = await import('../../src/scheduler/types');
let callCount = 0;
const promise = state.executeWithRetry(
'req-ratelimit',
async () => {
callCount++;
if (callCount < 2) {
return {
error:
'Rate limit exceeded: HTTP 429 Too Many Requests (code: rate_limit_exceeded) [retry after 0s]',
metadata: { rateLimitKind: 'rate_limit' as const },
};
}
return { output: 'recovered' };
},
{
isRateLimited: isProviderResponseRateLimited,
getRetryAfter: () => 0,
},
);
await vi.runAllTimersAsync();
const result = await promise;
expect(callCount).toBe(2);
expect((result as { output?: string }).output).toBe('recovered');
});
});
describe('executeWithRetry - retry behavior', () => {
it('should retry on rate limit and eventually succeed', async () => {
let attempt = 0;
// Start the request - it will retry after rate limit
const promise = state.executeWithRetry(
'req-1',
async () => {
attempt++;
if (attempt < 2) {
throw new Error('Rate limit');
}
return 'success';
},
{
// Use 0 for immediate retry to avoid timing-dependent flakiness
// (non-zero values race against slot queue's resetAt timer)
getRetryAfter: () => 0,
},
);
// Run all timers to completion (handles retry delays and rate limit resets)
await vi.runAllTimersAsync();
const result = await promise;
expect(attempt).toBe(2);
expect(result).toBe('success');
});
it('should emit retrying event', async () => {
const events: any[] = [];
state.on('request:retrying', (data) => events.push(data));
let attempt = 0;
const promise = state.executeWithRetry(
'req-1',
async () => {
attempt++;
if (attempt < 2) {
throw new Error('Rate limit');
}
return 'success';
},
{
// Use 0 for immediate retry to avoid timing-dependent flakiness
// (non-zero values race against slot queue's resetAt timer)
getRetryAfter: () => 0,
},
);
// Run all timers to completion
await vi.runAllTimersAsync();
await promise;
expect(events.length).toBe(1);
expect(events[0].attempt).toBe(1);
expect(events[0].reason).toBe('ratelimit');
});
it('should increment retriedRequests', async () => {
let attempt = 0;
const promise = state.executeWithRetry(
'req-1',
async () => {
attempt++;
if (attempt < 3) {
throw new Error('Rate limit');
}
return 'success';
},
{
// Use 0 for immediate retry to avoid timing-dependent flakiness
// (non-zero values race against slot queue's resetAt timer)
getRetryAfter: () => 0,
},
);
// Run all timers to completion
await vi.runAllTimersAsync();
await promise;
const metrics = state.getMetrics();
expect(metrics.retriedRequests).toBe(2);
});
});
describe('Header parsing and updates', () => {
it('should update state from rate limit headers', async () => {
await state.executeWithRetry('req-1', async () => 'success', {
getHeaders: () => ({
'x-ratelimit-remaining-requests': '50',
'x-ratelimit-limit-requests': '100',
}),
});
const metrics = state.getMetrics();
expect(metrics.completedRequests).toBe(1);
});
it('should emit ratelimit:learned only once per provider', async () => {
const events: any[] = [];
state.on('ratelimit:learned', (data) => events.push(data));
// First request with limit headers
await state.executeWithRetry('req-1', async () => 'success', {
getHeaders: () => ({
'x-ratelimit-limit-requests': '100',
}),
});
expect(events.length).toBe(1);
// Second request with limit headers - should not emit again
await state.executeWithRetry('req-2', async () => 'success', {
getHeaders: () => ({
'x-ratelimit-limit-requests': '100',
}),
});
expect(events.length).toBe(1); // Still 1, not 2
});
it('should not call markRateLimited for successful responses with retry-after headers', async () => {
// If a provider or proxy includes retry-after headers in successful (200) responses,
// the queue should NOT be blocked. Only rate-limited responses should trigger blocking.
const state2 = new ProviderRateLimitState({
rateLimitKey: 'test-no-block',
maxConcurrency: 5,
minConcurrency: 1,
retryPolicy: FAST_RETRY_POLICY,
});
// First request: successful response with retry-after-ms header
await state2.executeWithRetry('req-1', async () => 'success', {
getHeaders: () => ({
'retry-after-ms': '5000',
'x-ratelimit-remaining-requests': '50',
'x-ratelimit-limit-requests': '100',
}),
isRateLimited: () => false, // Response is NOT rate-limited
});
// Second request should succeed immediately without being blocked
// If markRateLimited was incorrectly called, remainingRequests would be 0
// and the queue would block until the reset timer fires
const result = await state2.executeWithRetry('req-2', async () => 'second-success', {
getHeaders: () => ({
'x-ratelimit-remaining-requests': '49',
'x-ratelimit-limit-requests': '100',
}),
isRateLimited: () => false,
});
expect(result).toBe('second-success');
const metrics = state2.getMetrics();
expect(metrics.completedRequests).toBe(2);
expect(metrics.rateLimitHits).toBe(0);
state2.dispose();
});
it('should call markRateLimited for rate-limited responses with retry-after headers', async () => {
const state2 = new ProviderRateLimitState({
rateLimitKey: 'test-block-on-429',
maxConcurrency: 5,
minConcurrency: 1,
retryPolicy: { maxRetries: 0, baseDelayMs: 1, maxDelayMs: 1, jitterFactor: 0 },
});
const hitEvents: any[] = [];
state2.on('ratelimit:hit', (data) => hitEvents.push(data));
// Rate-limited response with retry-after-ms header
try {
await state2.executeWithRetry('req-1', async () => 'rate-limited-result', {
getHeaders: () => ({
'retry-after-ms': '5000',
}),
isRateLimited: () => true, // Response IS rate-limited
});
} catch {
// Expected - rate limit exhausted with maxRetries=0
}
expect(hitEvents.length).toBe(1);
expect(hitEvents[0].retryAfterMs).toBeUndefined(); // getRetryAfter not provided
expect(state2.getMetrics().rateLimitHits).toBe(1);
state2.dispose();
});
});
describe('Adaptive concurrency', () => {
it('should decrease concurrency on rate limit', async () => {
// Create a new state with no retries for this test
const testState = new ProviderRateLimitState({
rateLimitKey: 'test-adaptive-decrease',
maxConcurrency: 5,
minConcurrency: 1,
retryPolicy: { maxRetries: 0, baseDelayMs: 1, maxDelayMs: 1, jitterFactor: 0 },
});
const events: any[] = [];
testState.on('concurrency:decreased', (data) => events.push(data));
try {
await testState.executeWithRetry(
'req-1',
async () => {
throw new Error('Rate limit');
},
{
// Use 0 for immediate retry (not used here since maxRetries=0, but for consistency)
getRetryAfter: () => 0,
},
);
} catch {}
testState.dispose();
expect(events.length).toBe(1);
expect(events[0].reason).toBe('ratelimit');
});
it('should increase concurrency after sustained success', async () => {
// Create a new state with no retries for this test
const testState = new ProviderRateLimitState({
rateLimitKey: 'test-adaptive-increase',
maxConcurrency: 5,
minConcurrency: 1,
retryPolicy: { maxRetries: 0, baseDelayMs: 1, maxDelayMs: 1, jitterFactor: 0 },
});
// First decrease concurrency via rate limit
try {
await testState.executeWithRetry(
'req-1',
async () => {
throw new Error('Rate limit');
},
{
// Use 0 for immediate retry (not used here since maxRetries=0, but for consistency)
getRetryAfter: () => 0,
},
);
} catch {}
// Advance past the rate limit reset time to allow subsequent requests
vi.advanceTimersByTime(10);
const events: any[] = [];
testState.on('concurrency:increased', (data) => events.push(data));
// 5 consecutive successes trigger recovery
for (let i = 0; i < 5; i++) {
await testState.executeWithRetry(`req-${i + 2}`, async () => 'success', {});
}
testState.dispose();
expect(events.length).toBe(1);
expect(events[0].reason).toBe('recovery');
});
});
describe('Proactive throttling', () => {
it('should emit warning when approaching limit', async () => {
const events: any[] = [];
state.on('ratelimit:warning', (data) => events.push(data));
await state.executeWithRetry('req-1', async () => 'success', {
getHeaders: () => ({
'x-ratelimit-remaining-requests': '5',
'x-ratelimit-limit-requests': '100',
}),
});
expect(events.length).toBe(1);
expect(events[0].requestRatio).toBe(0.05);
});
it('should proactively decrease concurrency when approaching limit', async () => {
const events: any[] = [];
state.on('concurrency:decreased', (data) => events.push(data));
await state.executeWithRetry('req-1', async () => 'success', {
getHeaders: () => ({
'x-ratelimit-remaining-requests': '5',
'x-ratelimit-limit-requests': '100',
}),
});
expect(events.length).toBe(1);
expect(events[0].reason).toBe('proactive');
});
});
describe('getQueueDepth', () => {
it('should return current queue depth', () => {
const depth = state.getQueueDepth();
expect(depth).toBe(0);
});
});
describe('getMetrics', () => {
it('should return all metrics', async () => {
await state.executeWithRetry('req-1', async () => 'success', {});
const metrics = state.getMetrics();
expect(metrics).toMatchObject({
rateLimitKey: 'test-provider',
activeRequests: 0,
maxConcurrency: 5,
queueDepth: 0,
totalRequests: 1,
completedRequests: 1,
failedRequests: 0,
rateLimitHits: 0,
retriedRequests: 0,
});
expect(typeof metrics.avgLatencyMs).toBe('number');
expect(typeof metrics.p50LatencyMs).toBe('number');
expect(typeof metrics.p99LatencyMs).toBe('number');
});
});
describe('dispose', () => {
it('should clean up resources', () => {
state.dispose();
// Should not throw when disposed
expect(() => state.getMetrics()).not.toThrow();
});
});
describe('Event emission', () => {
it('should emit slot:acquired event', async () => {
const events: any[] = [];
state.on('slot:acquired', (data) => events.push(data));
await state.executeWithRetry('req-1', async () => 'success', {});
expect(events.length).toBe(1);
expect(events[0].rateLimitKey).toBe('test-provider');
});
it('should emit slot:released event', async () => {
const events: any[] = [];
state.on('slot:released', (data) => events.push(data));
await state.executeWithRetry('req-1', async () => 'success', {});
expect(events.length).toBe(1);
expect(events[0].rateLimitKey).toBe('test-provider');
});
});
});