chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:24:08 +08:00
commit 0d3cb498a3
5438 changed files with 1316560 additions and 0 deletions
+395
View File
@@ -0,0 +1,395 @@
import { describe, expect, it } from 'vitest';
import { AdaptiveConcurrency, WARNING_THRESHOLD } from '../../src/scheduler/adaptiveConcurrency';
describe('AdaptiveConcurrency', () => {
describe('Constructor', () => {
it('should initialize with initial concurrency value', () => {
const ac = new AdaptiveConcurrency(10);
expect(ac.getCurrent()).toBe(10);
expect(ac.getInitial()).toBe(10);
});
it('should use default minimum of 1', () => {
const ac = new AdaptiveConcurrency(10);
expect(ac.getMin()).toBe(1);
});
it('should accept custom minimum value', () => {
const ac = new AdaptiveConcurrency(10, 3);
expect(ac.getMin()).toBe(3);
});
it('should enforce minimum of 1 even if lower value provided', () => {
const ac = new AdaptiveConcurrency(10, 0);
expect(ac.getMin()).toBe(1);
});
it('should clamp min when min >= initial', () => {
// When min >= initial, min should be clamped to initial
const ac = new AdaptiveConcurrency(5, 10);
expect(ac.getCurrent()).toBe(5);
expect(ac.getMin()).toBe(5); // Clamped to initial
expect(ac.getInitial()).toBe(5);
});
});
describe('recordSuccess - before threshold', () => {
it('should not change concurrency before RECOVERY_THRESHOLD', () => {
const ac = new AdaptiveConcurrency(10);
// Simulate rate limit to drop concurrency
ac.recordRateLimit();
expect(ac.getCurrent()).toBe(5);
// Record 4 successes (threshold is 5)
for (let i = 0; i < 4; i++) {
const result = ac.recordSuccess();
expect(result.changed).toBe(false);
expect(result.current).toBe(5);
expect(result.previous).toBe(5);
expect(ac.getCurrent()).toBe(5);
}
});
it('should not increase if already at initial value', () => {
const ac = new AdaptiveConcurrency(10);
const result = ac.recordSuccess();
expect(result.changed).toBe(false);
expect(result.current).toBe(10);
expect(ac.getCurrent()).toBe(10);
});
});
describe('recordSuccess - increases after threshold', () => {
it('should increase concurrency after RECOVERY_THRESHOLD successes', () => {
const ac = new AdaptiveConcurrency(10);
// Drop to minimum
ac.recordRateLimit(); // 10 → 5
ac.recordRateLimit(); // 5 → 2
expect(ac.getCurrent()).toBe(2);
// Record 5 successes to trigger recovery
for (let i = 0; i < 4; i++) {
const result = ac.recordSuccess();
expect(result.changed).toBe(false);
}
const result = ac.recordSuccess();
expect(result.changed).toBe(true);
expect(result.previous).toBe(2);
// ceil(2 * 1.5) = ceil(3) = 3
expect(result.current).toBe(3);
expect(result.reason).toBe('recovery');
expect(ac.getCurrent()).toBe(3);
});
});
describe('recordSuccess - recovery path verification', () => {
it('should follow recovery path: 1 → 2 → 3 → 5 → 8 → 10 (25 requests total)', () => {
const ac = new AdaptiveConcurrency(10, 1);
// Drop to minimum
ac.recordRateLimit(); // 10 → 5
ac.recordRateLimit(); // 5 → 2
ac.recordRateLimit(); // 2 → 1
expect(ac.getCurrent()).toBe(1);
let totalRequests = 0;
// 1 → 2 (5 successes)
for (let i = 0; i < 5; i++) {
ac.recordSuccess();
totalRequests++;
}
expect(ac.getCurrent()).toBe(2); // ceil(1 * 1.5) = 2
// 2 → 3 (5 successes)
for (let i = 0; i < 5; i++) {
ac.recordSuccess();
totalRequests++;
}
expect(ac.getCurrent()).toBe(3); // ceil(2 * 1.5) = 3
// 3 → 5 (5 successes)
for (let i = 0; i < 5; i++) {
ac.recordSuccess();
totalRequests++;
}
expect(ac.getCurrent()).toBe(5); // ceil(3 * 1.5) = ceil(4.5) = 5
// 5 → 8 (5 successes)
for (let i = 0; i < 5; i++) {
ac.recordSuccess();
totalRequests++;
}
expect(ac.getCurrent()).toBe(8); // ceil(5 * 1.5) = ceil(7.5) = 8
// 8 → 10 (5 successes, capped at initial)
for (let i = 0; i < 5; i++) {
ac.recordSuccess();
totalRequests++;
}
expect(ac.getCurrent()).toBe(10); // min(ceil(8 * 1.5), 10) = min(12, 10) = 10
expect(totalRequests).toBe(25);
});
});
describe('recordSuccess - caps at initial value', () => {
it('should not exceed initial concurrency value', () => {
const ac = new AdaptiveConcurrency(10);
// Drop concurrency
ac.recordRateLimit(); // 10 → 5
ac.recordRateLimit(); // 5 → 2
// Recover to initial
while (ac.getCurrent() < 10) {
for (let i = 0; i < 5; i++) {
ac.recordSuccess();
}
}
expect(ac.getCurrent()).toBe(10);
// Additional successes should not increase beyond initial
for (let i = 0; i < 10; i++) {
const result = ac.recordSuccess();
expect(result.changed).toBe(false);
expect(ac.getCurrent()).toBe(10);
}
});
});
describe('recordRateLimit - halves concurrency', () => {
it('should halve concurrency on rate limit', () => {
const ac = new AdaptiveConcurrency(10);
const result = ac.recordRateLimit();
expect(result.changed).toBe(true);
expect(result.previous).toBe(10);
expect(result.current).toBe(5); // floor(10 * 0.5) = 5
expect(result.reason).toBe('ratelimit');
expect(ac.getCurrent()).toBe(5);
});
it('should handle odd numbers correctly', () => {
const ac = new AdaptiveConcurrency(9);
const result = ac.recordRateLimit();
expect(result.changed).toBe(true);
expect(result.previous).toBe(9);
expect(result.current).toBe(4); // floor(9 * 0.5) = floor(4.5) = 4
expect(ac.getCurrent()).toBe(4);
});
});
describe('recordRateLimit - respects minimum', () => {
it('should not go below minimum concurrency', () => {
const ac = new AdaptiveConcurrency(10, 3);
ac.recordRateLimit(); // 10 → 5
ac.recordRateLimit(); // 5 → 2, but min is 3
expect(ac.getCurrent()).toBe(3);
const result = ac.recordRateLimit(); // stays at 3
expect(result.changed).toBe(false);
expect(result.current).toBe(3);
expect(ac.getCurrent()).toBe(3);
});
it('should respect minimum of 1 by default', () => {
const ac = new AdaptiveConcurrency(4);
ac.recordRateLimit(); // 4 → 2
ac.recordRateLimit(); // 2 → 1
const result = ac.recordRateLimit(); // stays at 1
expect(result.changed).toBe(false);
expect(result.current).toBe(1);
});
});
describe('recordRateLimit - resets consecutive successes', () => {
it('should reset consecutive success counter', () => {
const ac = new AdaptiveConcurrency(10);
ac.recordRateLimit(); // 10 → 5
// Build up 3 consecutive successes
for (let i = 0; i < 3; i++) {
ac.recordSuccess();
}
// Rate limit should reset counter
ac.recordRateLimit(); // 5 → 2
// Now need 5 more successes to trigger recovery
for (let i = 0; i < 4; i++) {
const result = ac.recordSuccess();
expect(result.changed).toBe(false);
}
const result = ac.recordSuccess();
expect(result.changed).toBe(true);
expect(result.current).toBe(3); // ceil(2 * 1.5) = 3
});
});
describe('recordApproachingLimit - no change above threshold', () => {
it('should not change concurrency when ratio >= WARNING_THRESHOLD', () => {
const ac = new AdaptiveConcurrency(10);
const result = ac.recordApproachingLimit(0.15); // 15% > 10%
expect(result.changed).toBe(false);
expect(result.current).toBe(10);
expect(result.reason).toBe('proactive');
expect(ac.getCurrent()).toBe(10);
});
it('should not change when exactly at WARNING_THRESHOLD', () => {
const ac = new AdaptiveConcurrency(10);
const result = ac.recordApproachingLimit(WARNING_THRESHOLD); // exactly 0.1
expect(result.changed).toBe(false);
expect(result.current).toBe(10);
});
});
describe('recordApproachingLimit - linear scaling calculations', () => {
it('should reduce to ~60% just below 10% remaining', () => {
const ac = new AdaptiveConcurrency(10);
// Use value just below threshold (0.1) to trigger reduction
const result = ac.recordApproachingLimit(0.099);
// reductionFactor = 0.2 + (0.099 / 0.10) * 0.4 = 0.2 + 0.396 = 0.596
// floor(10 * 0.596) = floor(5.96) = 5
expect(result.changed).toBe(true);
expect(result.previous).toBe(10);
expect(result.current).toBe(5);
expect(result.reason).toBe('proactive');
expect(ac.getCurrent()).toBe(5);
});
it('should reduce to ~40% at 5% remaining', () => {
const ac = new AdaptiveConcurrency(10);
const result = ac.recordApproachingLimit(0.05);
// reductionFactor = 0.2 + (0.05 / 0.10) * 0.4 = 0.2 + 0.2 = 0.4
// floor(10 * 0.4) = 4
expect(result.changed).toBe(true);
expect(result.previous).toBe(10);
expect(result.current).toBe(4);
expect(ac.getCurrent()).toBe(4);
});
it('should reduce to ~24% at 1% remaining', () => {
const ac = new AdaptiveConcurrency(10);
const result = ac.recordApproachingLimit(0.01);
// reductionFactor = 0.2 + (0.01 / 0.10) * 0.4 = 0.2 + 0.04 = 0.24
// floor(10 * 0.24) = floor(2.4) = 2
expect(result.changed).toBe(true);
expect(result.previous).toBe(10);
expect(result.current).toBe(2);
expect(ac.getCurrent()).toBe(2);
});
it('should reduce to 20% at 0% remaining', () => {
const ac = new AdaptiveConcurrency(10);
const result = ac.recordApproachingLimit(0.0);
// reductionFactor = 0.2 + (0.0 / 0.10) * 0.4 = 0.2
// floor(10 * 0.2) = 2
expect(result.changed).toBe(true);
expect(result.previous).toBe(10);
expect(result.current).toBe(2);
expect(ac.getCurrent()).toBe(2);
});
it('should verify linear interpolation at 7.5% remaining', () => {
const ac = new AdaptiveConcurrency(10);
const result = ac.recordApproachingLimit(0.075);
// reductionFactor = 0.2 + (0.075 / 0.10) * 0.4 = 0.2 + 0.3 = 0.5
// floor(10 * 0.5) = 5
expect(result.changed).toBe(true);
expect(result.current).toBe(5);
});
});
describe('recordApproachingLimit - respects minimum', () => {
it('should not reduce below minimum concurrency', () => {
const ac = new AdaptiveConcurrency(10, 5);
const result = ac.recordApproachingLimit(0.01);
// Would reduce to floor(10 * 0.24) = 2, but min is 5
expect(result.changed).toBe(true);
expect(result.current).toBe(5);
expect(ac.getCurrent()).toBe(5);
});
it('should not change when already at minimum', () => {
const ac = new AdaptiveConcurrency(10, 5);
// First reduction brings to minimum
ac.recordApproachingLimit(0.01);
expect(ac.getCurrent()).toBe(5);
// Second reduction should not change
const result = ac.recordApproachingLimit(0.01);
expect(result.changed).toBe(false);
expect(result.current).toBe(5);
});
});
describe('Edge cases', () => {
it('should handle ratio exactly at threshold boundary', () => {
const ac = new AdaptiveConcurrency(10);
// Just above threshold - no change
const result1 = ac.recordApproachingLimit(0.1000001);
expect(result1.changed).toBe(false);
// Just below threshold - should change
const result2 = ac.recordApproachingLimit(0.0999999);
expect(result2.changed).toBe(true);
});
it('should handle very small initial concurrency', () => {
const ac = new AdaptiveConcurrency(2, 1);
const result = ac.recordRateLimit();
expect(result.current).toBe(1); // floor(2 * 0.5) = 1
// Recovery
for (let i = 0; i < 5; i++) {
ac.recordSuccess();
}
expect(ac.getCurrent()).toBe(2); // min(ceil(1 * 1.5), 2) = min(2, 2) = 2
});
it('should handle negative ratio gracefully', () => {
const ac = new AdaptiveConcurrency(10);
const result = ac.recordApproachingLimit(-0.01);
// Negative ratios are clamped to 0, so clampedRatio = 0
expect(result.changed).toBe(true);
// reductionFactor = 0.2 + (0 / 0.10) * 0.4 = 0.2
// floor(10 * 0.2) = 2
expect(result.current).toBe(2);
});
it('should handle multiple sequential rate limits', () => {
const ac = new AdaptiveConcurrency(100, 1);
const results = [];
while (ac.getCurrent() > 1) {
results.push(ac.recordRateLimit());
}
// Verify exponential decay
expect(results[0].current).toBe(50); // 100 → 50
expect(results[1].current).toBe(25); // 50 → 25
expect(results[2].current).toBe(12); // 25 → 12
expect(results[3].current).toBe(6); // 12 → 6
expect(results[4].current).toBe(3); // 6 → 3
expect(results[5].current).toBe(1); // 3 → 1
});
});
});
+622
View File
@@ -0,0 +1,622 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { parseRateLimitHeaders, parseRetryAfter } from '../../src/scheduler/headerParser';
afterEach(() => {
vi.restoreAllMocks();
});
describe('parseRateLimitHeaders', () => {
describe('OpenAI format (x-ratelimit-*)', () => {
it('should parse OpenAI request rate limit headers', () => {
const headers = {
'x-ratelimit-remaining-requests': '100',
'x-ratelimit-limit-requests': '500',
};
const result = parseRateLimitHeaders(headers);
expect(result.remainingRequests).toBe(100);
expect(result.limitRequests).toBe(500);
});
it('should parse OpenAI token rate limit headers', () => {
const headers = {
'x-ratelimit-remaining-tokens': '50000',
'x-ratelimit-limit-tokens': '90000',
};
const result = parseRateLimitHeaders(headers);
expect(result.remainingTokens).toBe(50000);
expect(result.limitTokens).toBe(90000);
});
it('should parse generic x-ratelimit headers', () => {
const headers = {
'x-ratelimit-remaining': '250',
'x-ratelimit-limit': '1000',
};
const result = parseRateLimitHeaders(headers);
expect(result.remainingRequests).toBe(250);
expect(result.limitRequests).toBe(1000);
});
it('should parse x-ratelimit-reset as Unix timestamp in seconds', () => {
const futureTimestamp = Math.floor(Date.now() / 1000) + 60; // 60 seconds from now
const headers = {
'x-ratelimit-reset': futureTimestamp.toString(),
};
const result = parseRateLimitHeaders(headers);
expect(result.resetAt).toBe(futureTimestamp * 1000);
});
it('should parse x-ratelimit-reset as duration string', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
const headers = {
'x-ratelimit-reset-requests': '30s',
};
const result = parseRateLimitHeaders(headers);
expect(result.resetAt).toBe(now + 30000);
});
});
describe('Anthropic format (anthropic-ratelimit-*)', () => {
it('should parse Anthropic request rate limit headers', () => {
const headers = {
'anthropic-ratelimit-requests-remaining': '45',
'anthropic-ratelimit-requests-limit': '50',
};
const result = parseRateLimitHeaders(headers);
expect(result.remainingRequests).toBe(45);
expect(result.limitRequests).toBe(50);
});
it('should parse Anthropic token rate limit headers', () => {
const headers = {
'anthropic-ratelimit-tokens-remaining': '80000',
'anthropic-ratelimit-tokens-limit': '100000',
};
const result = parseRateLimitHeaders(headers);
expect(result.remainingTokens).toBe(80000);
expect(result.limitTokens).toBe(100000);
});
it('should parse anthropic-ratelimit-requests-reset as duration string', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
const headers = {
'anthropic-ratelimit-requests-reset': '1m30s',
};
const result = parseRateLimitHeaders(headers);
expect(result.resetAt).toBe(now + 90000);
});
});
describe('Generic format (ratelimit-*)', () => {
it('should parse generic ratelimit headers', () => {
const headers = {
'ratelimit-remaining': '300',
'ratelimit-limit': '600',
};
const result = parseRateLimitHeaders(headers);
expect(result.remainingRequests).toBe(300);
expect(result.limitRequests).toBe(600);
});
it('should parse ratelimit-reset as relative seconds', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
const headers = {
'ratelimit-reset': '120', // 2 minutes in the future (small number = relative)
};
const result = parseRateLimitHeaders(headers);
expect(result.resetAt).toBe(now + 120000);
});
});
describe('Case insensitivity', () => {
it('should handle mixed case header names', () => {
const headers = {
'X-RateLimit-Remaining-Requests': '100',
'X-RateLimit-Limit-Requests': '500',
'Anthropic-RateLimit-Tokens-Remaining': '50000',
};
const result = parseRateLimitHeaders(headers);
expect(result.remainingRequests).toBe(100);
expect(result.limitRequests).toBe(500);
expect(result.remainingTokens).toBe(50000);
});
it('should handle uppercase Retry-After header', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
const headers = {
'RETRY-AFTER': '60',
};
const result = parseRateLimitHeaders(headers);
expect(result.retryAfterMs).toBe(60000);
expect(result.resetAt).toBe(now + 60000);
});
});
describe('Header priority', () => {
it('should prioritize specific headers over generic ones', () => {
const headers = {
'x-ratelimit-remaining-requests': '100',
'x-ratelimit-remaining': '200',
};
const result = parseRateLimitHeaders(headers);
// Should use the more specific header
expect(result.remainingRequests).toBe(100);
});
it('should use first valid reset header', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
const headers = {
'x-ratelimit-reset-requests': '30s',
'x-ratelimit-reset': '60s',
};
const result = parseRateLimitHeaders(headers);
// Should use x-ratelimit-reset-requests (first in priority list)
expect(result.resetAt).toBe(now + 30000);
});
});
describe('Retry-After handling', () => {
it('should parse retry-after-ms header', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
const headers = {
'retry-after-ms': '5000',
};
const result = parseRateLimitHeaders(headers);
expect(result.retryAfterMs).toBe(5000);
expect(result.resetAt).toBe(now + 5000);
});
it('should parse retry-after as integer seconds', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
const headers = {
'retry-after': '30',
};
const result = parseRateLimitHeaders(headers);
expect(result.retryAfterMs).toBe(30000);
expect(result.resetAt).toBe(now + 30000);
});
it('should not override resetAt if already set', () => {
const now = Date.now();
const futureTime = now + 120000;
vi.spyOn(Date, 'now').mockReturnValue(now);
const headers = {
'x-ratelimit-reset': '2m',
'retry-after': '30',
};
const result = parseRateLimitHeaders(headers);
// resetAt should be from x-ratelimit-reset, not retry-after
expect(result.resetAt).toBe(futureTime);
expect(result.retryAfterMs).toBe(30000);
});
it('should prioritize retry-after-ms over retry-after', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
const headers = {
'retry-after-ms': '5000',
'retry-after': '30',
};
const result = parseRateLimitHeaders(headers);
expect(result.retryAfterMs).toBe(5000);
});
it('should fall back to retry-after when retry-after-ms is invalid', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
const headers = {
'retry-after-ms': 'Infinity',
'retry-after': '30',
};
const result = parseRateLimitHeaders(headers);
expect(result.retryAfterMs).toBe(30000);
expect(result.resetAt).toBe(now + 30000);
});
});
describe('Duration parsing', () => {
it('should parse milliseconds duration', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
const headers = {
'x-ratelimit-reset': '500ms',
};
const result = parseRateLimitHeaders(headers);
expect(result.resetAt).toBe(now + 500);
});
it('should parse seconds duration', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
const headers = {
'x-ratelimit-reset': '45s',
};
const result = parseRateLimitHeaders(headers);
expect(result.resetAt).toBe(now + 45000);
});
it('should parse minutes and seconds duration', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
const headers = {
'x-ratelimit-reset': '1m30s',
};
const result = parseRateLimitHeaders(headers);
expect(result.resetAt).toBe(now + 90000); // 60s + 30s
});
it('should parse hours and minutes duration', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
const headers = {
'x-ratelimit-reset': '1h30m',
};
const result = parseRateLimitHeaders(headers);
expect(result.resetAt).toBe(now + 5400000); // 3600s + 1800s
});
it('should parse complex duration with hours, minutes, and seconds', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
const headers = {
'x-ratelimit-reset': '2h15m30s',
};
const result = parseRateLimitHeaders(headers);
expect(result.resetAt).toBe(now + 8130000); // 7200s + 900s + 30s
});
it('should parse minutes-only duration', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
const headers = {
'x-ratelimit-reset': '5m',
};
const result = parseRateLimitHeaders(headers);
expect(result.resetAt).toBe(now + 300000);
});
it('should parse hours-only duration', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
const headers = {
'x-ratelimit-reset': '1h',
};
const result = parseRateLimitHeaders(headers);
expect(result.resetAt).toBe(now + 3600000);
});
});
describe('Edge cases', () => {
it('should return empty object for empty headers', () => {
const result = parseRateLimitHeaders({});
expect(result).toEqual({});
});
it('should ignore headers with invalid numeric values', () => {
const headers = {
'x-ratelimit-remaining-requests': 'invalid',
'x-ratelimit-limit-tokens': 'not-a-number',
};
const result = parseRateLimitHeaders(headers);
expect(result.remainingRequests).toBeUndefined();
expect(result.limitTokens).toBeUndefined();
});
it('should ignore non-finite numeric values', () => {
const overflowingNumber = '9'.repeat(400);
const headers = {
'x-ratelimit-remaining-requests': overflowingNumber,
'x-ratelimit-reset': 'Infinity',
'retry-after-ms': overflowingNumber,
};
const result = parseRateLimitHeaders(headers);
expect(result.remainingRequests).toBeUndefined();
expect(result.resetAt).toBeUndefined();
expect(result.retryAfterMs).toBeUndefined();
});
it('should ignore a duration string whose total overflows to non-finite', () => {
const overflowingDuration = `${'9'.repeat(400)}h`;
const result = parseRateLimitHeaders({ 'x-ratelimit-reset': overflowingDuration });
expect(result.resetAt).toBeUndefined();
});
it('should ignore negative reset values', () => {
const result = parseRateLimitHeaders({ 'x-ratelimit-reset': '-1' });
expect(result.resetAt).toBeUndefined();
});
it('should ignore negative values', () => {
const headers = {
'x-ratelimit-remaining-requests': '-5',
'x-ratelimit-limit-requests': '-100',
};
const result = parseRateLimitHeaders(headers);
expect(result.remainingRequests).toBeUndefined();
expect(result.limitRequests).toBeUndefined();
});
it('should handle zero values correctly', () => {
const headers = {
'x-ratelimit-remaining-requests': '0',
'x-ratelimit-limit-requests': '0',
};
const result = parseRateLimitHeaders(headers);
expect(result.remainingRequests).toBe(0);
expect(result.limitRequests).toBe(0);
});
it('should ignore invalid retry-after-ms values', () => {
const headers = {
'retry-after-ms': 'invalid',
};
const result = parseRateLimitHeaders(headers);
expect(result.retryAfterMs).toBeUndefined();
expect(result.resetAt).toBeUndefined();
});
it('should accept zero retry-after-ms as immediate retry', () => {
const headers = {
'retry-after-ms': '0',
};
const result = parseRateLimitHeaders(headers);
// 0 means "retry immediately" - this is valid per HTTP spec
expect(result.retryAfterMs).toBe(0);
});
it('should ignore negative retry-after-ms', () => {
const headers = {
'retry-after-ms': '-5',
};
const result = parseRateLimitHeaders(headers);
expect(result.retryAfterMs).toBeUndefined();
});
it('should handle missing header values', () => {
const headers = {
'x-ratelimit-remaining-requests': '',
'x-ratelimit-limit-requests': '',
};
const result = parseRateLimitHeaders(headers);
expect(result.remainingRequests).toBeUndefined();
expect(result.limitRequests).toBeUndefined();
});
it('should handle whitespace in numeric values', () => {
const headers = {
'x-ratelimit-remaining-requests': ' 100 ',
'retry-after': ' 30 ',
};
const result = parseRateLimitHeaders(headers);
expect(result.remainingRequests).toBe(100);
// parseRetryAfter trims the value before parsing
expect(result.retryAfterMs).toBe(30000);
});
it('should parse Unix timestamp in milliseconds', () => {
const futureTimestampMs = Date.now() + 60000;
const headers = {
'x-ratelimit-reset': futureTimestampMs.toString(),
};
const result = parseRateLimitHeaders(headers);
expect(result.resetAt).toBe(futureTimestampMs);
});
});
});
describe('parseRetryAfter', () => {
describe('Integer seconds', () => {
it('should parse integer seconds', () => {
const result = parseRetryAfter('60');
expect(result).toBe(60000);
});
it('should parse zero seconds', () => {
const result = parseRetryAfter('0');
expect(result).toBe(0);
});
it('should handle whitespace', () => {
const result = parseRetryAfter(' 30 ');
expect(result).toBe(30000);
});
it('should reject non-integer strings', () => {
const result = parseRetryAfter('30.5');
expect(result).toBeNull();
});
it('should reject strings with trailing text', () => {
const result = parseRetryAfter('30 seconds');
expect(result).toBeNull();
});
});
describe('HTTP-date format', () => {
it('should parse RFC 7231 HTTP-date', () => {
const now = Date.now();
const futureDate = new Date(now + 60000);
const httpDate = futureDate.toUTCString();
const result = parseRetryAfter(httpDate);
expect(result).toBeGreaterThan(0);
expect(result).toBeLessThanOrEqual(60000);
});
it('should parse ISO 8601 date string', () => {
const now = Date.now();
const futureDate = new Date(now + 120000);
const isoDate = futureDate.toISOString();
const result = parseRetryAfter(isoDate);
expect(result).toBeGreaterThan(0);
expect(result).toBeLessThanOrEqual(120000);
});
it('should return 0 for dates in the past', () => {
const pastDate = new Date(Date.now() - 60000);
const httpDate = pastDate.toUTCString();
const result = parseRetryAfter(httpDate);
expect(result).toBe(0);
});
it('should reject dates too far in the future', () => {
const veryFutureDate = new Date(Date.now() + 400 * 24 * 60 * 60 * 1000); // 400 days
const httpDate = veryFutureDate.toUTCString();
const result = parseRetryAfter(httpDate);
expect(result).toBeNull();
});
it('should reject dates too far in the past', () => {
const veryPastDate = new Date(Date.now() - 400 * 24 * 60 * 60 * 1000); // 400 days ago
const httpDate = veryPastDate.toUTCString();
const result = parseRetryAfter(httpDate);
expect(result).toBeNull();
});
});
describe('Invalid values', () => {
it('should return null for invalid string', () => {
const result = parseRetryAfter('invalid');
expect(result).toBeNull();
});
it('should reject non-finite seconds', () => {
expect(parseRetryAfter('Infinity')).toBeNull();
expect(parseRetryAfter('9'.repeat(400))).toBeNull();
});
it('should return null for empty string', () => {
const result = parseRetryAfter('');
expect(result).toBeNull();
});
it('should reject negative seconds', () => {
const result = parseRetryAfter('-30');
// Negative values are invalid per audit feedback - must be non-negative
expect(result).toBeNull();
});
it('should return null for duration format', () => {
// parseRetryAfter does not support duration format
const result = parseRetryAfter('30s');
expect(result).toBeNull();
});
});
});
@@ -0,0 +1,704 @@
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');
});
});
});
+244
View File
@@ -0,0 +1,244 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
isRateLimitWrapped,
wrapProvidersWithRateLimiting,
wrapProviderWithRateLimiting,
} from '../../src/scheduler/providerWrapper';
import { createMockProvider } from '../factories/provider';
import type { RateLimitRegistry } from '../../src/scheduler/rateLimitRegistry';
import type { ApiProvider, ProviderResponse } from '../../src/types/providers';
describe('providerWrapper', () => {
let mockProvider: ApiProvider;
let mockRegistry: RateLimitRegistry;
let mockExecute: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
mockExecute = vi.fn();
mockProvider = createMockProvider({
response: { output: 'test output' },
config: { apiKey: 'test-key' },
});
// Create a mock registry with the execute method
mockRegistry = {
execute: mockExecute,
} as unknown as RateLimitRegistry;
});
describe('wrapProviderWithRateLimiting', () => {
it('should wrap provider callApi with registry.execute', async () => {
mockExecute.mockImplementation(async (_provider, callFn) => callFn());
const wrappedProvider = wrapProviderWithRateLimiting(mockProvider, mockRegistry);
await wrappedProvider.callApi('test prompt');
expect(mockExecute).toHaveBeenCalledTimes(1);
expect(mockExecute).toHaveBeenCalledWith(
mockProvider,
expect.any(Function),
expect.objectContaining({
getHeaders: expect.any(Function),
isRateLimited: expect.any(Function),
getRetryAfter: expect.any(Function),
}),
);
});
it('should preserve other provider properties', () => {
const wrappedProvider = wrapProviderWithRateLimiting(mockProvider, mockRegistry);
expect(wrappedProvider.id()).toBe('test-provider');
expect(wrappedProvider.config).toEqual({ apiKey: 'test-key' });
});
it('should preserve id() method from class prototype', () => {
// This tests the specific bug where spread operator doesn't copy prototype methods.
// When a provider is a class instance, id() is on the prototype, not an own property.
class TestProvider implements ApiProvider {
callApi = vi.fn().mockResolvedValue({ output: 'test' });
id(): string {
return 'class-based-provider';
}
}
const classProvider = new TestProvider();
const wrappedProvider = wrapProviderWithRateLimiting(classProvider, mockRegistry);
// Verify that id() works on the wrapped provider
expect(wrappedProvider.id()).toBe('class-based-provider');
});
it('should not double-wrap already wrapped providers', () => {
const wrappedOnce = wrapProviderWithRateLimiting(mockProvider, mockRegistry);
const wrappedTwice = wrapProviderWithRateLimiting(wrappedOnce, mockRegistry);
expect(wrappedOnce).toBe(wrappedTwice);
});
it('should mark wrapped provider with symbol', () => {
const wrappedProvider = wrapProviderWithRateLimiting(mockProvider, mockRegistry);
expect(isRateLimitWrapped(wrappedProvider)).toBe(true);
expect(isRateLimitWrapped(mockProvider)).toBe(false);
});
});
describe('wrapProvidersWithRateLimiting', () => {
it('should wrap multiple providers', () => {
const provider1 = createMockProvider({ id: 'provider-1' });
const provider2 = createMockProvider({ id: 'provider-2' });
const wrappedProviders = wrapProvidersWithRateLimiting([provider1, provider2], mockRegistry);
expect(wrappedProviders).toHaveLength(2);
expect(isRateLimitWrapped(wrappedProviders[0])).toBe(true);
expect(isRateLimitWrapped(wrappedProviders[1])).toBe(true);
});
});
describe('rate limit detection callbacks', () => {
it('should detect rate limit from HTTP 429 status', async () => {
let capturedOptions: any;
mockExecute.mockImplementation(async (_provider, callFn, options) => {
capturedOptions = options;
return callFn();
});
const wrappedProvider = wrapProviderWithRateLimiting(mockProvider, mockRegistry);
await wrappedProvider.callApi('test');
const result: ProviderResponse = {
output: 'test',
metadata: { http: { status: 429, statusText: 'Too Many Requests', headers: {} } },
};
expect(capturedOptions.isRateLimited(result, undefined)).toBe(true);
});
it('should detect explicit transient rate limit metadata without error text parsing', async () => {
let capturedOptions: any;
mockExecute.mockImplementation(async (_provider, callFn, options) => {
capturedOptions = options;
return callFn();
});
const wrappedProvider = wrapProviderWithRateLimiting(mockProvider, mockRegistry);
await wrappedProvider.callApi('test');
const result: ProviderResponse = {
error: 'SDK throttle',
metadata: { rateLimitKind: 'rate_limit' },
};
expect(capturedOptions.isRateLimited(result, undefined)).toBe(true);
});
it('should detect rate limit from error message', async () => {
let capturedOptions: any;
mockExecute.mockImplementation(async (_provider, callFn, options) => {
capturedOptions = options;
return callFn();
});
const wrappedProvider = wrapProviderWithRateLimiting(mockProvider, mockRegistry);
await wrappedProvider.callApi('test');
const error = new Error('Error 429: rate limit exceeded');
expect(capturedOptions.isRateLimited(undefined, error)).toBe(true);
});
it('should extract headers from metadata.http.headers', async () => {
let capturedOptions: any;
mockExecute.mockImplementation(async (_provider, callFn, options) => {
capturedOptions = options;
return callFn();
});
const wrappedProvider = wrapProviderWithRateLimiting(mockProvider, mockRegistry);
await wrappedProvider.callApi('test');
const result: ProviderResponse = {
output: 'test',
metadata: {
http: {
status: 200,
statusText: 'OK',
headers: { 'retry-after': '60' },
},
},
};
const headers = capturedOptions.getHeaders(result);
expect(headers).toEqual({ 'retry-after': '60' });
});
it('should parse retry-after header', async () => {
let capturedOptions: any;
mockExecute.mockImplementation(async (_provider, callFn, options) => {
capturedOptions = options;
return callFn();
});
const wrappedProvider = wrapProviderWithRateLimiting(mockProvider, mockRegistry);
await wrappedProvider.callApi('test');
const result: ProviderResponse = {
output: 'test',
metadata: {
http: {
status: 429,
statusText: 'Too Many Requests',
headers: { 'Retry-After': '30' },
},
},
};
// Headers are normalized to lowercase in getRetryAfter
const retryAfter = capturedOptions.getRetryAfter(result, undefined);
expect(retryAfter).toBe(30000); // 30 seconds in ms
});
it('should ignore non-finite retry-after-ms and fall back to retry-after', async () => {
let capturedOptions: any;
mockExecute.mockImplementation(async (_provider, callFn, options) => {
capturedOptions = options;
return callFn();
});
const wrappedProvider = wrapProviderWithRateLimiting(mockProvider, mockRegistry);
await wrappedProvider.callApi('test');
const result: ProviderResponse = {
output: 'test',
metadata: {
http: {
status: 429,
statusText: 'Too Many Requests',
headers: { 'retry-after-ms': '9'.repeat(400), 'retry-after': '30' },
},
},
};
expect(capturedOptions.getRetryAfter(result, undefined)).toBe(30000);
});
it('should ignore a non-finite retry-after parsed from the error message', async () => {
let capturedOptions: any;
mockExecute.mockImplementation(async (_provider, callFn, options) => {
capturedOptions = options;
return callFn();
});
const wrappedProvider = wrapProviderWithRateLimiting(mockProvider, mockRegistry);
await wrappedProvider.callApi('test');
const overflowingRetryAfter = `retry after ${'9'.repeat(400)}`;
const error = new Error(overflowingRetryAfter);
expect(capturedOptions.getRetryAfter(undefined, error)).toBeUndefined();
});
});
});
+276
View File
@@ -0,0 +1,276 @@
import { describe, expect, it } from 'vitest';
import { getRateLimitKey } from '../../src/scheduler/rateLimitKey';
import type { ApiProvider } from '../../src/types/providers';
function createMockProvider(id: string, config: Record<string, any> = {}): ApiProvider {
return {
id: () => id,
config,
callApi: async () => ({ output: 'mock' }),
} as ApiProvider;
}
describe('getRateLimitKey', () => {
describe('Basic provider ID', () => {
it('should return provider ID when no config', () => {
const provider = createMockProvider('openai:gpt-4');
const key = getRateLimitKey(provider);
expect(key).toBe('openai:gpt-4');
});
it('should return provider ID when config is empty', () => {
const provider = createMockProvider('openai:gpt-4', {});
const key = getRateLimitKey(provider);
expect(key).toBe('openai:gpt-4');
});
it('should return provider ID when config has irrelevant fields', () => {
const provider = createMockProvider('openai:gpt-4', {
temperature: 0.7,
maxTokens: 1000,
});
const key = getRateLimitKey(provider);
expect(key).toBe('openai:gpt-4');
});
});
describe('API key hashing', () => {
it('should include hashed API key in key', () => {
const provider = createMockProvider('openai:gpt-4', {
apiKey: 'sk-test-key-12345',
});
const key = getRateLimitKey(provider);
expect(key).toMatch(/^openai:gpt-4\[.{12}\]$/);
});
it('should generate different keys for different API keys', () => {
const provider1 = createMockProvider('openai:gpt-4', {
apiKey: 'sk-key-1',
});
const provider2 = createMockProvider('openai:gpt-4', {
apiKey: 'sk-key-2',
});
const key1 = getRateLimitKey(provider1);
const key2 = getRateLimitKey(provider2);
expect(key1).not.toBe(key2);
});
it('should generate same key for same API key', () => {
const provider1 = createMockProvider('openai:gpt-4', {
apiKey: 'sk-same-key',
});
const provider2 = createMockProvider('openai:gpt-4', {
apiKey: 'sk-same-key',
});
const key1 = getRateLimitKey(provider1);
const key2 = getRateLimitKey(provider2);
expect(key1).toBe(key2);
});
it('should not expose API key in the output', () => {
const apiKey = 'sk-super-secret-key-abc123xyz';
const provider = createMockProvider('openai:gpt-4', { apiKey });
const key = getRateLimitKey(provider);
expect(key).not.toContain('secret');
expect(key).not.toContain('abc123');
expect(key).not.toContain(apiKey);
});
});
describe('API base URL handling', () => {
it('should include apiBaseUrl in key', () => {
const provider = createMockProvider('openai:gpt-4', {
apiBaseUrl: 'https://custom.openai.azure.com',
});
const key = getRateLimitKey(provider);
expect(key).toMatch(/^openai:gpt-4\[.{12}\]$/);
});
it('should generate different keys for different base URLs', () => {
const provider1 = createMockProvider('openai:gpt-4', {
apiBaseUrl: 'https://api.openai.com',
});
const provider2 = createMockProvider('openai:gpt-4', {
apiBaseUrl: 'https://custom.azure.com',
});
const key1 = getRateLimitKey(provider1);
const key2 = getRateLimitKey(provider2);
expect(key1).not.toBe(key2);
});
});
describe('Region handling', () => {
it('should include region in key', () => {
const provider = createMockProvider('anthropic:claude-3', {
region: 'us-east-1',
});
const key = getRateLimitKey(provider);
expect(key).toMatch(/^anthropic:claude-3\[.{12}\]$/);
});
it('should generate different keys for different regions', () => {
const provider1 = createMockProvider('anthropic:claude-3', {
region: 'us-east-1',
});
const provider2 = createMockProvider('anthropic:claude-3', {
region: 'eu-west-1',
});
const key1 = getRateLimitKey(provider1);
const key2 = getRateLimitKey(provider2);
expect(key1).not.toBe(key2);
});
});
describe('Organization handling', () => {
it('should include organization in key', () => {
const provider = createMockProvider('openai:gpt-4', {
organization: 'org-123',
});
const key = getRateLimitKey(provider);
expect(key).toMatch(/^openai:gpt-4\[.{12}\]$/);
});
it('should generate different keys for different organizations', () => {
const provider1 = createMockProvider('openai:gpt-4', {
organization: 'org-1',
});
const provider2 = createMockProvider('openai:gpt-4', {
organization: 'org-2',
});
const key1 = getRateLimitKey(provider1);
const key2 = getRateLimitKey(provider2);
expect(key1).not.toBe(key2);
});
});
describe('Combined config handling', () => {
it('should generate consistent key for same combined config', () => {
const config = {
apiKey: 'sk-key',
apiBaseUrl: 'https://api.openai.com',
region: 'us-east-1',
organization: 'org-1',
};
const provider1 = createMockProvider('openai:gpt-4', config);
const provider2 = createMockProvider('openai:gpt-4', config);
const key1 = getRateLimitKey(provider1);
const key2 = getRateLimitKey(provider2);
expect(key1).toBe(key2);
});
it('should generate different keys for different combined configs', () => {
const provider1 = createMockProvider('openai:gpt-4', {
apiKey: 'sk-key',
organization: 'org-1',
});
const provider2 = createMockProvider('openai:gpt-4', {
apiKey: 'sk-key',
organization: 'org-2',
});
const key1 = getRateLimitKey(provider1);
const key2 = getRateLimitKey(provider2);
expect(key1).not.toBe(key2);
});
it('should be stable regardless of config property order', () => {
const provider1 = createMockProvider('openai:gpt-4', {
apiKey: 'sk-key',
region: 'us-east-1',
organization: 'org-1',
});
const provider2 = createMockProvider('openai:gpt-4', {
organization: 'org-1',
apiKey: 'sk-key',
region: 'us-east-1',
});
const key1 = getRateLimitKey(provider1);
const key2 = getRateLimitKey(provider2);
expect(key1).toBe(key2);
});
});
describe('Different provider IDs', () => {
it('should generate different keys for different providers', () => {
const provider1 = createMockProvider('openai:gpt-4', {
apiKey: 'sk-same-key',
});
const provider2 = createMockProvider('anthropic:claude-3', {
apiKey: 'sk-same-key',
});
const key1 = getRateLimitKey(provider1);
const key2 = getRateLimitKey(provider2);
expect(key1).not.toBe(key2);
expect(key1).toMatch(/^openai:gpt-4/);
expect(key2).toMatch(/^anthropic:claude-3/);
});
});
describe('Edge cases', () => {
it('should handle undefined config', () => {
const provider = {
id: () => 'test-provider',
callApi: async () => ({ output: 'mock' }),
} as ApiProvider;
const key = getRateLimitKey(provider);
expect(key).toBe('test-provider');
});
it('should handle empty string values', () => {
const provider = createMockProvider('openai:gpt-4', {
apiKey: '',
});
const key = getRateLimitKey(provider);
// Empty string is falsy so should not be included
expect(key).toBe('openai:gpt-4');
});
it('should handle special characters in provider ID', () => {
const provider = createMockProvider('custom:model/v1.2-beta', {
apiKey: 'test-key',
});
const key = getRateLimitKey(provider);
expect(key).toMatch(/^custom:model\/v1\.2-beta\[.{12}\]$/);
});
});
});
@@ -0,0 +1,155 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RateLimitRegistry } from '../../src/scheduler/rateLimitRegistry';
import { getFetchRetryContextMaxRetries } from '../../src/util/fetch/retryContext';
import type { ApiProvider } from '../../src/types/providers';
function createProvider(maxRetries?: unknown, id = 'test-provider'): ApiProvider {
const config = maxRetries === undefined ? {} : { maxRetries };
return {
id: () => id,
config,
callApi: vi.fn(),
} as unknown as ApiProvider;
}
async function runRateLimitedCall(maxRetries?: unknown): Promise<number> {
const registry = new RateLimitRegistry({
maxConcurrency: 1,
queueTimeoutMs: 100,
});
const provider = createProvider(maxRetries);
const callFn = vi.fn().mockResolvedValue({ status: 429 });
try {
await expect(
registry.execute(provider, callFn, {
isRateLimited: (result) => (result as { status?: number } | undefined)?.status === 429,
getRetryAfter: () => 0,
}),
).rejects.toThrow('Rate limit exceeded');
} finally {
registry.dispose();
}
return callFn.mock.calls.length;
}
describe('RateLimitRegistry integration - provider maxRetries', () => {
afterEach(() => {
vi.resetAllMocks();
vi.unstubAllEnvs();
});
it('should propagate provider maxRetries into the fetch retry context', async () => {
const registry = new RateLimitRegistry({
maxConcurrency: 1,
queueTimeoutMs: 100,
});
const provider = createProvider(0);
const callFn = vi.fn().mockImplementation(async () => getFetchRetryContextMaxRetries());
try {
const contextValue = await registry.execute(provider, callFn);
expect(contextValue).toBe(0);
} finally {
registry.dispose();
}
});
it('should clear an outer retry context when a nested provider has no maxRetries', async () => {
// Use distinct provider ids so the outer and inner calls map to separate
// ProviderRateLimitState instances — otherwise shared slot queue state
// could mask the ALS-scope behavior we're asserting.
const registry = new RateLimitRegistry({
maxConcurrency: 2,
queueTimeoutMs: 100,
});
const outerProvider = createProvider(0, 'outer-provider');
const innerProvider = createProvider(undefined, 'inner-provider');
try {
const contextValue = await registry.execute(outerProvider, () =>
registry.execute(innerProvider, async () => getFetchRetryContextMaxRetries()),
);
expect(contextValue).toBeUndefined();
} finally {
registry.dispose();
}
});
it('should propagate fetch retry context when scheduler is disabled', async () => {
vi.stubEnv('PROMPTFOO_DISABLE_ADAPTIVE_SCHEDULER', 'true');
try {
const registry = new RateLimitRegistry({
maxConcurrency: 1,
queueTimeoutMs: 100,
});
const provider = createProvider(0);
const callFn = vi.fn().mockImplementation(async () => getFetchRetryContextMaxRetries());
try {
const contextValue = await registry.execute(provider, callFn);
expect(contextValue).toBe(0);
} finally {
registry.dispose();
}
} finally {
vi.unstubAllEnvs();
}
});
it('should not retry when provider maxRetries is 0', async () => {
expect(await runRateLimitedCall(0)).toBe(1);
});
it('should retry provider maxRetries + 1 total attempts', async () => {
expect(await runRateLimitedCall(2)).toBe(3);
});
it('should parse numeric string maxRetries values', async () => {
expect(await runRateLimitedCall('2')).toBe(3);
});
it('should use default scheduler retries (4 attempts) when provider maxRetries is not set', async () => {
expect(await runRateLimitedCall(undefined)).toBe(4);
});
it('should ignore negative maxRetries and use default scheduler retries', async () => {
expect(await runRateLimitedCall(-1)).toBe(4);
});
it('should ignore non-integer string maxRetries values', async () => {
expect(await runRateLimitedCall('2.5')).toBe(4);
});
it('should ignore non-integer number maxRetries values', async () => {
expect(await runRateLimitedCall(2.5)).toBe(4);
});
it('should isolate retry context per concurrent provider', async () => {
const registry = new RateLimitRegistry({
maxConcurrency: 4,
queueTimeoutMs: 100,
});
async function capture(): Promise<number | undefined> {
const ctx = getFetchRetryContextMaxRetries();
// Yield so both calls can interleave before returning — guards against
// a regression where the context from the later call leaks into the earlier.
await new Promise((resolve) => setImmediate(resolve));
return ctx;
}
try {
const [a, b] = await Promise.all([
registry.execute(createProvider(0, 'p0'), capture),
registry.execute(createProvider(5, 'p5'), capture),
]);
expect(a).toBe(0);
expect(b).toBe(5);
} finally {
registry.dispose();
}
});
});
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_RETRY_POLICY, getRetryDelay, shouldRetry } from '../../src/scheduler/retryPolicy';
import { HttpRateLimitError } from '../../src/util/fetch/errors';
describe('getRetryDelay', () => {
const policy = { ...DEFAULT_RETRY_POLICY, jitterFactor: 0 }; // No jitter for predictable tests
it('should return 0 for immediate retry when serverRetryAfterMs is 0', () => {
const delay = getRetryDelay(1, policy, 0);
expect(delay).toBe(0);
});
it('should use server retry-after when provided', () => {
const delay = getRetryDelay(1, policy, 5000);
expect(delay).toBe(5000);
});
it('should cap delay at maxDelayMs', () => {
const delay = getRetryDelay(1, policy, 120000); // 2 minutes
expect(delay).toBe(policy.maxDelayMs);
});
it('should use exponential backoff when no server retry-after', () => {
const delay1 = getRetryDelay(1, policy, undefined);
const delay2 = getRetryDelay(2, policy, undefined);
const delay3 = getRetryDelay(3, policy, undefined);
// Exponential: baseDelay * 2^attempt
expect(delay1).toBe(policy.baseDelayMs * 2);
expect(delay2).toBe(policy.baseDelayMs * 4);
expect(delay3).toBe(policy.baseDelayMs * 8);
});
it('should cap exponential backoff at maxDelayMs', () => {
const delay = getRetryDelay(10, policy, undefined); // 2^10 = 1024x base
expect(delay).toBe(policy.maxDelayMs);
});
});
describe('shouldRetry', () => {
it('should retry on rate limit error', () => {
const result = shouldRetry(0, undefined, true, DEFAULT_RETRY_POLICY);
expect(result).toBe(true);
});
it('should not retry after max retries exceeded', () => {
const result = shouldRetry(
DEFAULT_RETRY_POLICY.maxRetries,
undefined,
true,
DEFAULT_RETRY_POLICY,
);
expect(result).toBe(false);
});
it('should retry on rate limit (429 should be detected externally and set isRateLimited=true)', () => {
// Note: 429 detection happens in the caller, not in shouldRetry
// The caller should pass isRateLimited=true for 429 errors
const error = new Error('HTTP 429: Too Many Requests');
const result = shouldRetry(0, error, true, DEFAULT_RETRY_POLICY);
expect(result).toBe(true);
});
it('should retry on 503 error message', () => {
const error = new Error('HTTP 503: Service Unavailable');
const result = shouldRetry(0, error, false, DEFAULT_RETRY_POLICY);
expect(result).toBe(true);
});
it('should not retry on generic error', () => {
const error = new Error('Some random error');
const result = shouldRetry(0, error, false, DEFAULT_RETRY_POLICY);
expect(result).toBe(false);
});
it('should handle error with undefined message gracefully', () => {
// Test edge case where error.message could be undefined
const error = new Error('placeholder');
(error as { message: string | undefined }).message = undefined;
const result = shouldRetry(0, error, false, DEFAULT_RETRY_POLICY);
expect(result).toBe(false);
});
it('should retry on timeout error', () => {
const error = new Error('Request timeout after 30000ms');
const result = shouldRetry(0, error, false, DEFAULT_RETRY_POLICY);
expect(result).toBe(true);
});
it('should retry on network error', () => {
const error = new Error('ECONNRESET');
const result = shouldRetry(0, error, false, DEFAULT_RETRY_POLICY);
expect(result).toBe(true);
});
it('should retry on bad record mac error', () => {
const error = new Error('bad record mac');
const result = shouldRetry(0, error, false, DEFAULT_RETRY_POLICY);
expect(result).toBe(true);
});
it('should retry on EPROTO error', () => {
const error = new Error('write EPROTO 00000000:error:0A000126');
const result = shouldRetry(0, error, false, DEFAULT_RETRY_POLICY);
expect(result).toBe(true);
});
it('should not retry on permanent certificate errors', () => {
const error = new Error('self signed certificate');
const result = shouldRetry(0, error, false, DEFAULT_RETRY_POLICY);
expect(result).toBe(false);
});
it('should not retry HttpRateLimitError with kind="quota" even when isRateLimited=true', () => {
// The transport-level fail-fast must not be undermined by the
// scheduler's retry-on-429 logic. Without this, a hard quota would
// get amplified by `policy.maxRetries` calls against an exhausted
// account.
const error = new HttpRateLimitError({ status: 429, code: 'insufficient_quota' });
expect(shouldRetry(0, error, true, DEFAULT_RETRY_POLICY)).toBe(false);
});
it('should retry HttpRateLimitError with kind="rate_limit"', () => {
const error = new HttpRateLimitError({
status: 429,
code: 'rate_limit_exceeded',
retryAfterMs: 5000,
});
expect(shouldRetry(0, error, true, DEFAULT_RETRY_POLICY)).toBe(true);
});
});
+919
View File
@@ -0,0 +1,919 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { SlotQueue } from '../../src/scheduler/slotQueue';
import type { ParsedRateLimitHeaders } from '../../src/scheduler/headerParser';
describe('SlotQueue', () => {
// Scoped unhandledRejection handler - saves and restores original listeners
// Note: Vitest may still report these as "unhandled errors" but they won't fail tests
let originalListeners: NodeJS.UnhandledRejectionListener[];
beforeAll(() => {
originalListeners = process.listeners(
'unhandledRejection',
) as NodeJS.UnhandledRejectionListener[];
process.removeAllListeners('unhandledRejection');
process.on('unhandledRejection', (reason: unknown) => {
if (reason instanceof Error && reason.message === 'Queue disposed') {
return; // Suppress expected dispose errors
}
throw reason;
});
});
afterAll(() => {
process.removeAllListeners('unhandledRejection');
for (const listener of originalListeners) {
process.on('unhandledRejection', listener);
}
});
let queue: SlotQueue;
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
// Dispose queue - waiting requests will be rejected with "Queue disposed"
// These rejections are expected and handled by trackAcquire() below
queue?.dispose();
vi.restoreAllMocks();
vi.useRealTimers();
});
// Helper to track acquire promises that may be pending at cleanup
// Adds a no-op catch handler to prevent unhandled rejection warnings
function trackAcquire(promise: Promise<void>): Promise<void> {
promise.catch(() => {
// Expected rejection on dispose - ignore
});
return promise;
}
describe('Constructor - initial state', () => {
it('should initialize with correct default values', () => {
queue = new SlotQueue({
maxConcurrency: 5,
minConcurrency: 1,
});
expect(queue.getMaxConcurrency()).toBe(5);
expect(queue.getActiveCount()).toBe(0);
expect(queue.getQueueDepth()).toBe(0);
expect(queue.getResetAt()).toBeNull();
});
it('should use default queue timeout when not specified', async () => {
queue = new SlotQueue({
maxConcurrency: 0, // No capacity - force queuing
minConcurrency: 0,
});
// Default timeout is 5 minutes (300000ms), we'll verify this indirectly
// by checking that a request doesn't timeout before 5 minutes
const _promise = queue.acquire('test-1').catch(() => {}); // Handle rejection on dispose
vi.advanceTimersByTime(4 * 60 * 1000); // 4 minutes
expect(queue.getQueueDepth()).toBe(1); // Still waiting
});
it('should use custom queue timeout when specified', async () => {
queue = new SlotQueue({
maxConcurrency: 1,
minConcurrency: 1,
queueTimeoutMs: 1000,
});
// Fill capacity
await queue.acquire('test-1');
expect(queue.getActiveCount()).toBe(1);
// Second request should timeout
const promise2 = queue.acquire('test-2');
expect(queue.getQueueDepth()).toBe(1);
vi.advanceTimersByTime(1001);
await expect(promise2).rejects.toThrow('timed out after 1000ms in queue');
});
});
describe('acquire/release - basic slot allocation', () => {
beforeEach(() => {
queue = new SlotQueue({
maxConcurrency: 3,
minConcurrency: 1,
});
});
it('should immediately acquire slot when capacity available', async () => {
const promise = queue.acquire('test-1');
await expect(promise).resolves.toBeUndefined();
expect(queue.getActiveCount()).toBe(1);
expect(queue.getQueueDepth()).toBe(0);
});
it('should release slot and decrement active count', async () => {
await queue.acquire('test-1');
expect(queue.getActiveCount()).toBe(1);
queue.release();
expect(queue.getActiveCount()).toBe(0);
});
it('should acquire multiple slots up to max concurrency', async () => {
await queue.acquire('test-1');
await queue.acquire('test-2');
await queue.acquire('test-3');
expect(queue.getActiveCount()).toBe(3);
expect(queue.getQueueDepth()).toBe(0);
});
it('should call onSlotAcquired callback with queue depth', async () => {
const onSlotAcquired = vi.fn();
queue = new SlotQueue({
maxConcurrency: 2,
minConcurrency: 1,
onSlotAcquired,
});
await queue.acquire('test-1');
expect(onSlotAcquired).toHaveBeenCalledWith(0);
await queue.acquire('test-2');
expect(onSlotAcquired).toHaveBeenCalledWith(0); // No queue yet
trackAcquire(queue.acquire('test-3')); // This will queue
queue.release(); // Process queued request
await vi.runAllTimersAsync();
expect(onSlotAcquired).toHaveBeenCalledWith(0); // Queue depth after test-3 was dequeued
});
it('should call onSlotReleased callback with queue depth', async () => {
const onSlotReleased = vi.fn();
queue = new SlotQueue({
maxConcurrency: 1,
minConcurrency: 1,
onSlotReleased,
});
await queue.acquire('test-1');
trackAcquire(queue.acquire('test-2')); // Queue this
trackAcquire(queue.acquire('test-3')); // Queue this too
expect(queue.getQueueDepth()).toBe(2);
queue.release();
// Callback is called with queue depth BEFORE processing
expect(onSlotReleased).toHaveBeenCalledWith(2);
});
});
describe('acquire - concurrent requests respect maxConcurrency', () => {
it('should queue requests beyond max concurrency', async () => {
queue = new SlotQueue({
maxConcurrency: 2,
minConcurrency: 1,
});
await queue.acquire('test-1');
await queue.acquire('test-2');
expect(queue.getActiveCount()).toBe(2);
trackAcquire(queue.acquire('test-3'));
trackAcquire(queue.acquire('test-4'));
expect(queue.getActiveCount()).toBe(2);
expect(queue.getQueueDepth()).toBe(2);
});
it('should not exceed max concurrency even with rapid concurrent requests', async () => {
queue = new SlotQueue({
maxConcurrency: 3,
minConcurrency: 1,
});
const _promises = Array.from({ length: 10 }, (_, i) =>
trackAcquire(queue.acquire(`test-${i}`)),
);
expect(queue.getActiveCount()).toBe(3);
expect(queue.getQueueDepth()).toBe(7);
});
});
describe('acquire - queued requests are FIFO', () => {
it('should process queued requests in FIFO order', async () => {
queue = new SlotQueue({
maxConcurrency: 1,
minConcurrency: 1,
});
const order: string[] = [];
await queue.acquire('test-1');
const p2 = queue.acquire('test-2').then(() => order.push('test-2'));
const p3 = queue.acquire('test-3').then(() => order.push('test-3'));
const p4 = queue.acquire('test-4').then(() => order.push('test-4'));
expect(queue.getQueueDepth()).toBe(3);
queue.release();
await p2;
expect(order[0]).toBe('test-2');
queue.release();
await p3;
expect(order[1]).toBe('test-3');
queue.release();
await p4;
expect(order[2]).toBe('test-4');
});
});
describe('acquire - race condition prevention', () => {
it('should not over-allocate slots under concurrent load', async () => {
queue = new SlotQueue({
maxConcurrency: 5,
minConcurrency: 1,
});
// Launch 100 concurrent requests
const _promises = Array.from({ length: 100 }, (_, i) =>
trackAcquire(queue.acquire(`test-${i}`)),
);
// Should have exactly max concurrency active, rest queued
expect(queue.getActiveCount()).toBe(5);
expect(queue.getQueueDepth()).toBe(95);
// Release one and verify only one more is acquired
queue.release();
expect(queue.getActiveCount()).toBe(5);
expect(queue.getQueueDepth()).toBe(94);
});
it('should maintain exact capacity during rapid acquire/release cycles', async () => {
queue = new SlotQueue({
maxConcurrency: 3,
minConcurrency: 1,
});
// Fill initial capacity
await queue.acquire('initial-1');
await queue.acquire('initial-2');
await queue.acquire('initial-3');
expect(queue.getActiveCount()).toBe(3);
// Queue many requests
const _promises = Array.from({ length: 20 }, (_, i) =>
trackAcquire(queue.acquire(`queued-${i}`)),
);
expect(queue.getQueueDepth()).toBe(20);
// Rapid release/verify cycle
for (let i = 0; i < 10; i++) {
queue.release();
expect(queue.getActiveCount()).toBe(3); // Should always maintain max
}
});
});
describe('updateRateLimitState - updates internal state', () => {
beforeEach(() => {
queue = new SlotQueue({
maxConcurrency: 5,
minConcurrency: 1,
});
});
it('should update remaining requests and limit', () => {
const parsed: ParsedRateLimitHeaders = {
remainingRequests: 100,
limitRequests: 500,
};
queue.updateRateLimitState(parsed);
const ratio = queue.getRemainingRatio();
expect(ratio.requests).toBe(0.2); // 100/500
});
it('should update remaining tokens and limit', () => {
const parsed: ParsedRateLimitHeaders = {
remainingTokens: 50000,
limitTokens: 100000,
};
queue.updateRateLimitState(parsed);
const ratio = queue.getRemainingRatio();
expect(ratio.tokens).toBe(0.5); // 50000/100000
});
it('should update resetAt timestamp', () => {
const resetTime = Date.now() + 60000;
const parsed: ParsedRateLimitHeaders = {
resetAt: resetTime,
};
queue.updateRateLimitState(parsed);
expect(queue.getResetAt()).toBe(resetTime);
});
it('should update all fields together', () => {
const resetTime = Date.now() + 120000;
const parsed: ParsedRateLimitHeaders = {
remainingRequests: 45,
limitRequests: 50,
remainingTokens: 80000,
limitTokens: 100000,
resetAt: resetTime,
};
queue.updateRateLimitState(parsed);
const ratio = queue.getRemainingRatio();
expect(ratio.requests).toBe(0.9);
expect(ratio.tokens).toBe(0.8);
expect(queue.getResetAt()).toBe(resetTime);
});
});
describe('markRateLimited - sets quota to 0, schedules reset', () => {
beforeEach(() => {
queue = new SlotQueue({
maxConcurrency: 5,
minConcurrency: 1,
});
});
it('should set remaining requests and tokens to 0', () => {
queue.markRateLimited(60000);
const _ratio = queue.getRemainingRatio();
// ratios will be null since we don't have limits set
// but we can verify through behavior - requests should be blocked
expect(queue.getResetAt()).toBe(Date.now() + 60000);
});
it('should schedule reset with retryAfterMs', () => {
const retryAfter = 5000;
queue.markRateLimited(retryAfter);
expect(queue.getResetAt()).toBe(Date.now() + retryAfter);
});
it('should use default 60s reset when no retryAfterMs provided and no existing resetAt', () => {
queue.markRateLimited();
expect(queue.getResetAt()).toBe(Date.now() + 60000);
});
it('should use later reset time when new retryAfterMs is longer', () => {
queue.updateRateLimitState({ resetAt: Date.now() + 30000 });
queue.markRateLimited(60000);
expect(queue.getResetAt()).toBe(Date.now() + 60000);
});
it('should block new acquisitions until reset time', async () => {
// Set up initial state with limits
queue.updateRateLimitState({
remainingRequests: 10,
limitRequests: 100,
});
await queue.acquire('test-1');
expect(queue.getActiveCount()).toBe(1);
// Mark rate limited
queue.markRateLimited(5000);
// Try to acquire - should be blocked
trackAcquire(queue.acquire('test-2'));
expect(queue.getQueueDepth()).toBe(1);
expect(queue.getActiveCount()).toBe(1);
// Advance past reset time
vi.advanceTimersByTime(5001);
// Now should be acquired
expect(queue.getActiveCount()).toBe(2);
expect(queue.getQueueDepth()).toBe(0);
});
});
describe('markRateLimited - preserves existing resetAt if no retryAfterMs', () => {
beforeEach(() => {
queue = new SlotQueue({
maxConcurrency: 5,
minConcurrency: 1,
});
});
it('should preserve existing resetAt when retryAfterMs not provided', () => {
const existingResetAt = Date.now() + 120000;
queue.updateRateLimitState({ resetAt: existingResetAt });
queue.markRateLimited(); // No retryAfterMs
expect(queue.getResetAt()).toBe(existingResetAt);
});
it('should keep existing resetAt when retryAfterMs is 0', () => {
const existingResetAt = Date.now() + 90000;
queue.updateRateLimitState({ resetAt: existingResetAt });
queue.markRateLimited(0); // Zero retryAfterMs
expect(queue.getResetAt()).toBe(existingResetAt);
});
it('should prefer existing resetAt when new retryAfterMs is shorter', () => {
const existingResetAt = Date.now() + 120000;
queue.updateRateLimitState({ resetAt: existingResetAt });
queue.markRateLimited(30000); // Shorter than existing
expect(queue.getResetAt()).toBe(existingResetAt);
});
});
describe('isQuotaExhausted - blocks when requests exhausted', () => {
beforeEach(() => {
queue = new SlotQueue({
maxConcurrency: 5,
minConcurrency: 1,
});
});
it('should block acquisitions when remaining requests is 0', async () => {
queue.updateRateLimitState({
remainingRequests: 0,
limitRequests: 100,
resetAt: Date.now() + 60000,
});
trackAcquire(queue.acquire('test-1'));
expect(queue.getActiveCount()).toBe(0);
expect(queue.getQueueDepth()).toBe(1);
});
it('should allow acquisitions when remaining requests > 0', async () => {
queue.updateRateLimitState({
remainingRequests: 10,
limitRequests: 100,
});
await queue.acquire('test-1');
expect(queue.getActiveCount()).toBe(1);
expect(queue.getQueueDepth()).toBe(0);
});
});
describe('isQuotaExhausted - blocks when tokens exhausted', () => {
beforeEach(() => {
queue = new SlotQueue({
maxConcurrency: 5,
minConcurrency: 1,
});
});
it('should block acquisitions when remaining tokens is 0', async () => {
queue.updateRateLimitState({
remainingTokens: 0,
limitTokens: 100000,
resetAt: Date.now() + 60000,
});
trackAcquire(queue.acquire('test-1'));
expect(queue.getActiveCount()).toBe(0);
expect(queue.getQueueDepth()).toBe(1);
});
it('should allow acquisitions when remaining tokens > 0', async () => {
queue.updateRateLimitState({
remainingTokens: 50000,
limitTokens: 100000,
});
await queue.acquire('test-1');
expect(queue.getActiveCount()).toBe(1);
expect(queue.getQueueDepth()).toBe(0);
});
it('should block when either requests OR tokens exhausted', async () => {
queue.updateRateLimitState({
remainingRequests: 10,
limitRequests: 100,
remainingTokens: 0,
limitTokens: 100000,
resetAt: Date.now() + 60000,
});
trackAcquire(queue.acquire('test-1'));
expect(queue.getActiveCount()).toBe(0);
expect(queue.getQueueDepth()).toBe(1);
});
});
describe('isQuotaExhausted - clears stale state after reset time', () => {
beforeEach(() => {
queue = new SlotQueue({
maxConcurrency: 5,
minConcurrency: 1,
});
});
it('should clear quota state when reset time has passed', async () => {
const resetAt = Date.now() + 5000;
queue.updateRateLimitState({
remainingRequests: 0,
limitRequests: 100,
resetAt,
});
// Should be blocked initially
trackAcquire(queue.acquire('test-1'));
expect(queue.getQueueDepth()).toBe(1);
// Advance past reset time - timer fires and processes queue
vi.advanceTimersByTime(5001);
// Queue should be processed
expect(queue.getActiveCount()).toBe(1);
expect(queue.getQueueDepth()).toBe(0);
expect(queue.getResetAt()).toBeNull();
});
it('should allow acquisition immediately after reset time without waiting for timer', async () => {
const resetAt = Date.now() + 3000;
queue.updateRateLimitState({
remainingRequests: 0,
resetAt,
});
// Advance past reset
vi.advanceTimersByTime(3001);
// Should acquire immediately
await queue.acquire('test-1');
expect(queue.getActiveCount()).toBe(1);
});
});
describe('scheduleResetProcessing - processes queue after reset', () => {
beforeEach(() => {
queue = new SlotQueue({
maxConcurrency: 5,
minConcurrency: 1,
});
});
it('should process queued requests after reset timer fires', async () => {
queue.updateRateLimitState({
remainingRequests: 0,
resetAt: Date.now() + 5000,
});
trackAcquire(queue.acquire('test-1'));
trackAcquire(queue.acquire('test-2'));
expect(queue.getQueueDepth()).toBe(2);
expect(queue.getActiveCount()).toBe(0);
// Advance to reset time
vi.advanceTimersByTime(5000);
// Should process queue
expect(queue.getActiveCount()).toBe(2);
expect(queue.getQueueDepth()).toBe(0);
expect(queue.getResetAt()).toBeNull();
});
it('should cancel existing reset timer when new reset is scheduled', async () => {
const now = Date.now();
queue.updateRateLimitState({
remainingRequests: 0,
resetAt: now + 10000,
});
trackAcquire(queue.acquire('test-1'));
expect(queue.getQueueDepth()).toBe(1);
// Advance time a bit
vi.advanceTimersByTime(1000);
// Schedule new reset with shorter time from current point
queue.updateRateLimitState({
resetAt: Date.now() + 2000,
});
// Old timer would fire at 10000ms total, we're at 1000ms
// New timer should fire at 1000 + 2000 = 3000ms total
vi.advanceTimersByTime(1999);
expect(queue.getQueueDepth()).toBe(1);
// New timer should fire now (total 3000ms)
vi.advanceTimersByTime(1);
expect(queue.getActiveCount()).toBe(1);
});
it('should not schedule reset if queue is empty', () => {
queue.updateRateLimitState({
remainingRequests: 0,
resetAt: Date.now() + 5000,
});
// No requests queued, so no timer should be set
// This is verified indirectly - we can't inspect private resetTimer
// but we can verify that advancing time doesn't cause issues
vi.advanceTimersByTime(10000);
expect(queue.getQueueDepth()).toBe(0);
});
});
describe('Queue timeout - rejects after timeout', () => {
it('should reject queued request after timeout expires', async () => {
queue = new SlotQueue({
maxConcurrency: 1,
minConcurrency: 1,
queueTimeoutMs: 5000,
});
await queue.acquire('test-1'); // Fill capacity
const promise2 = queue.acquire('test-2'); // Queue this
vi.advanceTimersByTime(5001);
await expect(promise2).rejects.toThrow('timed out after 5000ms in queue');
expect(queue.getQueueDepth()).toBe(0);
});
it('should clear timeout when request is processed', async () => {
queue = new SlotQueue({
maxConcurrency: 1,
minConcurrency: 1,
queueTimeoutMs: 10000,
});
await queue.acquire('test-1');
const promise2 = queue.acquire('test-2');
expect(queue.getQueueDepth()).toBe(1);
// Release before timeout
queue.release();
// Should be processed, not rejected
await expect(promise2).resolves.toBeUndefined();
expect(queue.getActiveCount()).toBe(1);
});
it('should handle timeout value of 0 as disabled', async () => {
queue = new SlotQueue({
maxConcurrency: 1,
minConcurrency: 1,
queueTimeoutMs: 0,
});
await queue.acquire('test-1');
trackAcquire(queue.acquire('test-2'));
// Advance a very long time - should not timeout
vi.advanceTimersByTime(1000000);
expect(queue.getQueueDepth()).toBe(1);
});
});
describe('getRemainingRatio - returns correct ratios', () => {
beforeEach(() => {
queue = new SlotQueue({
maxConcurrency: 5,
minConcurrency: 1,
});
});
it('should return null ratios when no limits set', () => {
const ratio = queue.getRemainingRatio();
expect(ratio.requests).toBeNull();
expect(ratio.tokens).toBeNull();
});
it('should calculate request ratio correctly', () => {
queue.updateRateLimitState({
remainingRequests: 25,
limitRequests: 100,
});
const ratio = queue.getRemainingRatio();
expect(ratio.requests).toBe(0.25);
expect(ratio.tokens).toBeNull();
});
it('should calculate token ratio correctly', () => {
queue.updateRateLimitState({
remainingTokens: 75000,
limitTokens: 100000,
});
const ratio = queue.getRemainingRatio();
expect(ratio.requests).toBeNull();
expect(ratio.tokens).toBe(0.75);
});
it('should calculate both ratios when both are set', () => {
queue.updateRateLimitState({
remainingRequests: 40,
limitRequests: 50,
remainingTokens: 30000,
limitTokens: 100000,
});
const ratio = queue.getRemainingRatio();
expect(ratio.requests).toBe(0.8);
expect(ratio.tokens).toBe(0.3);
});
it('should return null when limit is 0', () => {
queue.updateRateLimitState({
remainingRequests: 10,
limitRequests: 0,
});
const ratio = queue.getRemainingRatio();
expect(ratio.requests).toBeNull();
});
it('should handle 0 remaining correctly', () => {
queue.updateRateLimitState({
remainingRequests: 0,
limitRequests: 100,
});
const ratio = queue.getRemainingRatio();
expect(ratio.requests).toBe(0);
});
});
describe('dispose - clears timers and rejects waiting', () => {
beforeEach(() => {
queue = new SlotQueue({
maxConcurrency: 1,
minConcurrency: 1,
});
});
it('should reject all waiting requests on dispose', async () => {
await queue.acquire('test-1');
const promise2 = queue.acquire('test-2');
const promise3 = queue.acquire('test-3');
expect(queue.getQueueDepth()).toBe(2);
queue.dispose();
await expect(promise2).rejects.toThrow('Queue disposed');
await expect(promise3).rejects.toThrow('Queue disposed');
expect(queue.getQueueDepth()).toBe(0);
});
it('should clear reset timer on dispose', async () => {
queue.updateRateLimitState({
remainingRequests: 0,
resetAt: Date.now() + 60000,
});
const promise = queue.acquire('test-1');
queue.dispose();
// Handle the expected rejection
await expect(promise).rejects.toThrow('Queue disposed');
// Advancing time should not cause any timer to fire
vi.advanceTimersByTime(100000);
// No assertion needed - just verifying no errors thrown
});
it('should clear queue timeout timers on dispose', async () => {
queue = new SlotQueue({
maxConcurrency: 1,
minConcurrency: 1,
queueTimeoutMs: 5000,
});
await queue.acquire('test-1');
const promise2 = queue.acquire('test-2');
queue.dispose();
// Timeout timer should be cleared, so advancing time won't cause timeout error
vi.advanceTimersByTime(10000);
await expect(promise2).rejects.toThrow('Queue disposed'); // Not timeout error
});
});
describe('setMaxConcurrency - adjust concurrency limit', () => {
beforeEach(() => {
queue = new SlotQueue({
maxConcurrency: 5,
minConcurrency: 2,
});
});
it('should update max concurrency', () => {
queue.setMaxConcurrency(10);
expect(queue.getMaxConcurrency()).toBe(10);
});
it('should enforce minimum concurrency', () => {
queue.setMaxConcurrency(1);
expect(queue.getMaxConcurrency()).toBe(2); // Min is 2
});
it('should process queue when concurrency is increased', async () => {
// Fill to capacity (5)
await queue.acquire('test-1');
await queue.acquire('test-2');
await queue.acquire('test-3');
await queue.acquire('test-4');
await queue.acquire('test-5');
// Queue more
trackAcquire(queue.acquire('test-6'));
trackAcquire(queue.acquire('test-7'));
expect(queue.getActiveCount()).toBe(5);
expect(queue.getQueueDepth()).toBe(2);
// Increase concurrency
queue.setMaxConcurrency(7);
expect(queue.getActiveCount()).toBe(7);
expect(queue.getQueueDepth()).toBe(0);
});
it('should not process queue when concurrency is decreased', async () => {
await queue.acquire('test-1');
await queue.acquire('test-2');
expect(queue.getActiveCount()).toBe(2);
queue.setMaxConcurrency(3); // Lower than current capacity (5)
await queue.acquire('test-3');
expect(queue.getActiveCount()).toBe(3);
});
});
describe('release - edge cases', () => {
beforeEach(() => {
queue = new SlotQueue({
maxConcurrency: 5,
minConcurrency: 1,
});
});
it('should not go negative when release() called without acquire()', () => {
// Call release without any acquire
queue.release();
queue.release();
queue.release();
// Should still be 0, not negative
expect(queue.getActiveCount()).toBe(0);
// Should still be able to acquire normally
queue.acquire('test-1');
expect(queue.getActiveCount()).toBe(1);
});
it('should handle unbalanced release calls gracefully', async () => {
await queue.acquire('test-1');
expect(queue.getActiveCount()).toBe(1);
queue.release();
queue.release(); // Extra release
queue.release(); // Extra release
expect(queue.getActiveCount()).toBe(0);
});
});
});
+293
View File
@@ -0,0 +1,293 @@
import { describe, expect, it } from 'vitest';
import {
getProviderResponseHeaders,
isProviderResponseRateLimited,
isTransientConnectionError,
} from '../../src/scheduler/types';
import type { ProviderResponse } from '../../src/types/providers';
describe('isProviderResponseRateLimited', () => {
describe('HTTP status detection', () => {
it('should detect 429 status in metadata.http.status', () => {
const result: ProviderResponse = {
output: 'error',
metadata: { http: { status: 429, statusText: 'Too Many Requests', headers: {} } },
};
expect(isProviderResponseRateLimited(result, undefined)).toBe(true);
});
it('should not flag other status codes', () => {
const result: ProviderResponse = {
output: 'success',
metadata: { http: { status: 200, statusText: 'OK', headers: {} } },
};
expect(isProviderResponseRateLimited(result, undefined)).toBe(false);
});
});
describe('Error field detection', () => {
it('should detect 429 in result.error string', () => {
const result: ProviderResponse = {
output: '',
error: 'HTTP 429: Too Many Requests',
};
expect(isProviderResponseRateLimited(result, undefined)).toBe(true);
});
it('should detect "rate limit" in result.error (case insensitive)', () => {
const result: ProviderResponse = {
output: '',
error: 'Rate Limit Exceeded',
};
expect(isProviderResponseRateLimited(result, undefined)).toBe(true);
});
it('should handle undefined result.error gracefully', () => {
const result: ProviderResponse = {
output: 'success',
};
expect(isProviderResponseRateLimited(result, undefined)).toBe(false);
});
});
describe('Thrown error detection', () => {
it('should detect 429 in error.message', () => {
const error = new Error('Request failed with status 429');
expect(isProviderResponseRateLimited(undefined, error)).toBe(true);
});
it('should detect "rate limit" in error.message (case insensitive)', () => {
const error = new Error('API rate limit exceeded');
expect(isProviderResponseRateLimited(undefined, error)).toBe(true);
});
it('should detect "too many requests" in error.message', () => {
const error = new Error('Too many requests, please slow down');
expect(isProviderResponseRateLimited(undefined, error)).toBe(true);
});
it('should handle error with undefined message gracefully', () => {
const error = new Error('placeholder');
(error as { message: string | undefined }).message = undefined;
expect(isProviderResponseRateLimited(undefined, error)).toBe(false);
});
it('should handle undefined error gracefully', () => {
expect(isProviderResponseRateLimited(undefined, undefined)).toBe(false);
});
});
describe('Combined detection', () => {
it('should detect rate limit in result even with error present', () => {
const result: ProviderResponse = {
output: '',
error: '429 rate limited',
};
const error = new Error('Some other error');
expect(isProviderResponseRateLimited(result, error)).toBe(true);
});
it('should detect rate limit in error even with result present', () => {
const result: ProviderResponse = {
output: 'success',
};
const error = new Error('Rate limit exceeded');
expect(isProviderResponseRateLimited(result, error)).toBe(true);
});
});
describe('Edge cases', () => {
it('should not false-positive on similar strings', () => {
const result: ProviderResponse = {
output: 'The rate of change is 42.9%',
error: 'Rating limit exceeded expectations',
};
// "Rating limit" is not "rate limit" - should not match
// But actually... "rating limit" contains "rate" and "limit" separately
// Let me check the actual logic - it uses .includes('rate limit')
expect(isProviderResponseRateLimited(result, undefined)).toBe(false);
});
it('should handle empty result object', () => {
const result: ProviderResponse = {};
expect(isProviderResponseRateLimited(result, undefined)).toBe(false);
});
it('should handle result with nested undefined metadata', () => {
const result: ProviderResponse = {
output: 'success',
metadata: {},
};
expect(isProviderResponseRateLimited(result, undefined)).toBe(false);
});
});
});
describe('getProviderResponseHeaders', () => {
it('should extract headers from metadata.http.headers', () => {
const result: ProviderResponse = {
output: 'success',
metadata: {
http: {
status: 200,
statusText: 'OK',
headers: {
'x-ratelimit-remaining': '10',
'x-ratelimit-limit': '100',
},
},
},
};
const headers = getProviderResponseHeaders(result);
expect(headers).toEqual({
'x-ratelimit-remaining': '10',
'x-ratelimit-limit': '100',
});
});
it('should extract headers from metadata.headers as fallback', () => {
const result: ProviderResponse = {
output: 'success',
metadata: {
headers: {
'retry-after': '60',
},
},
};
const headers = getProviderResponseHeaders(result);
expect(headers).toEqual({
'retry-after': '60',
});
});
it('should prefer metadata.http.headers over metadata.headers', () => {
const result: ProviderResponse = {
output: 'success',
metadata: {
http: {
status: 200,
statusText: 'OK',
headers: { 'x-from-http': 'true' },
},
headers: { 'x-from-meta': 'true' },
},
};
const headers = getProviderResponseHeaders(result);
expect(headers).toEqual({ 'x-from-http': 'true' });
});
it('should return undefined for result without headers', () => {
const result: ProviderResponse = {
output: 'success',
};
expect(getProviderResponseHeaders(result)).toBeUndefined();
});
it('should return undefined for undefined result', () => {
expect(getProviderResponseHeaders(undefined)).toBeUndefined();
});
it('should return undefined for result with empty metadata', () => {
const result: ProviderResponse = {
output: 'success',
metadata: {},
};
expect(getProviderResponseHeaders(result)).toBeUndefined();
});
});
describe('isTransientConnectionError', () => {
it('should detect bad record mac errors', () => {
expect(isTransientConnectionError(new Error('bad record mac'))).toBe(true);
});
it('should detect EPROTO errors', () => {
expect(isTransientConnectionError(new Error('write EPROTO 00000000:error:0A000126'))).toBe(
true,
);
});
it('should detect ECONNRESET errors', () => {
expect(isTransientConnectionError(new Error('ECONNRESET'))).toBe(true);
});
it('should detect socket hang up errors', () => {
expect(isTransientConnectionError(new Error('socket hang up'))).toBe(true);
});
it('should return false for undefined error', () => {
expect(isTransientConnectionError(undefined)).toBe(false);
});
it('should return false for non-connection errors', () => {
expect(isTransientConnectionError(new Error('Invalid API key'))).toBe(false);
});
it('should return false for rate limit errors', () => {
expect(isTransientConnectionError(new Error('429 Too Many Requests'))).toBe(false);
});
it('should not match EPROTO with permanent certificate/config errors', () => {
// wrong version number (HTTPS→HTTP mismatch)
expect(
isTransientConnectionError(
new Error('write EPROTO 00000000:error:0A000102:SSL routines::wrong version number'),
),
).toBe(false);
// self-signed certificate
expect(
isTransientConnectionError(
new Error('write EPROTO: self signed certificate in certificate chain'),
),
).toBe(false);
// unable to verify
expect(
isTransientConnectionError(new Error('write EPROTO: unable to verify the first certificate')),
).toBe(false);
// unknown CA
expect(isTransientConnectionError(new Error('write EPROTO: unknown ca'))).toBe(false);
// expired certificate
expect(isTransientConnectionError(new Error('write EPROTO: certificate has expired'))).toBe(
false,
);
// cert keyword
expect(isTransientConnectionError(new Error('write EPROTO: cert_untrusted'))).toBe(false);
});
it('should still match plain EPROTO without permanent phrases', () => {
expect(isTransientConnectionError(new Error('write EPROTO 00000000:error:0A000126'))).toBe(
true,
);
});
it('should detect ECONNRESET via error.code', () => {
const error = new Error('read failed');
(error as any).code = 'ECONNRESET';
expect(isTransientConnectionError(error)).toBe(true);
});
it('should detect EPIPE via error.code', () => {
const error = new Error('write failed');
(error as any).code = 'EPIPE';
expect(isTransientConnectionError(error)).toBe(true);
});
it('should not match ECONNREFUSED via error.code', () => {
const error = new Error('connect failed');
(error as any).code = 'ECONNREFUSED';
expect(isTransientConnectionError(error)).toBe(false);
});
it('should not match permanent SSL/TLS errors', () => {
expect(isTransientConnectionError(new Error('self signed certificate'))).toBe(false);
expect(isTransientConnectionError(new Error('unable to verify the first certificate'))).toBe(
false,
);
expect(isTransientConnectionError(new Error('certificate has expired'))).toBe(false);
expect(isTransientConnectionError(new Error('UNABLE_TO_GET_ISSUER_CERT'))).toBe(false);
expect(isTransientConnectionError(new Error('ssl routines:ssl3_read_bytes'))).toBe(false);
expect(isTransientConnectionError(new Error('tls alert unknown ca'))).toBe(false);
expect(isTransientConnectionError(new Error('wrong version number'))).toBe(false);
});
});