133 lines
3.9 KiB
TypeScript
133 lines
3.9 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
classifyRateLimitError,
|
|
planRateLimitResume,
|
|
type RateLimitAssessment,
|
|
} from '../src/rate-limit-classifier.js';
|
|
|
|
const NOW_MS = Date.UTC(2026, 6, 15, 10, 0, 0);
|
|
const MINUTE_MS = 60_000;
|
|
const HOUR_MS = 60 * MINUTE_MS;
|
|
|
|
describe('classifyRateLimitError', () => {
|
|
it.each([
|
|
'HTTP 429 Too Many Requests',
|
|
'Anthropic error: rate_limit_error',
|
|
'OpenAI: Rate limit reached for this model',
|
|
'Your quota exceeded',
|
|
])('detects a status-only rate-limit error: %s', (text) => {
|
|
expect(classifyRateLimitError(text, NOW_MS)).toEqual({
|
|
isRateLimit: true,
|
|
resetAtMs: null,
|
|
source: 'status-only',
|
|
});
|
|
});
|
|
|
|
it('parses Retry-After delta-seconds', () => {
|
|
expect(classifyRateLimitError('Retry-After: 120', NOW_MS)).toEqual({
|
|
isRateLimit: true,
|
|
resetAtMs: NOW_MS + 2 * MINUTE_MS,
|
|
source: 'retry-after',
|
|
});
|
|
});
|
|
|
|
it('parses Retry-After HTTP-dates with the caller-provided clock', () => {
|
|
expect(
|
|
classifyRateLimitError('Retry-After: Wed, 15 Jul 2026 10:05:00 GMT', NOW_MS),
|
|
).toEqual({
|
|
isRateLimit: true,
|
|
resetAtMs: NOW_MS + 5 * MINUTE_MS,
|
|
source: 'retry-after',
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['30s', 30_000],
|
|
['2m', 2 * MINUTE_MS],
|
|
['1h', HOUR_MS],
|
|
])('parses try-again duration %s', (duration, delayMs) => {
|
|
expect(classifyRateLimitError(`Rate limited; try again in ${duration}`, NOW_MS)).toEqual({
|
|
isRateLimit: true,
|
|
resetAtMs: NOW_MS + delayMs,
|
|
source: 'reset-phrase',
|
|
});
|
|
});
|
|
|
|
it('parses an ISO reset timestamp', () => {
|
|
expect(
|
|
classifyRateLimitError('Rate limit reached; resets at 2026-07-15T10:45:00Z', NOW_MS),
|
|
).toEqual({
|
|
isRateLimit: true,
|
|
resetAtMs: NOW_MS + 45 * MINUTE_MS,
|
|
source: 'reset-phrase',
|
|
});
|
|
});
|
|
|
|
it('parses an HH:MM reset as the next UTC clock occurrence', () => {
|
|
expect(classifyRateLimitError('Rate limit resets at 10:30', NOW_MS)).toEqual({
|
|
isRateLimit: true,
|
|
resetAtMs: NOW_MS + 30 * MINUTE_MS,
|
|
source: 'reset-phrase',
|
|
});
|
|
});
|
|
|
|
it('clamps reset times to at least 30 seconds from now', () => {
|
|
expect(classifyRateLimitError('try again in 1s', NOW_MS).resetAtMs).toBe(NOW_MS + 30_000);
|
|
});
|
|
|
|
it('clamps reset times to no more than 12 hours from now', () => {
|
|
expect(classifyRateLimitError('try again in 24h', NOW_MS).resetAtMs).toBe(
|
|
NOW_MS + 12 * HOUR_MS,
|
|
);
|
|
});
|
|
|
|
it('returns none for unrelated errors', () => {
|
|
expect(classifyRateLimitError('ECONNREFUSED while contacting model', NOW_MS)).toEqual({
|
|
isRateLimit: false,
|
|
resetAtMs: null,
|
|
source: 'none',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('planRateLimitResume', () => {
|
|
it('adds a 30-second buffer to a parsed reset time', () => {
|
|
const assessment: RateLimitAssessment = {
|
|
isRateLimit: true,
|
|
resetAtMs: NOW_MS + 5 * MINUTE_MS,
|
|
source: 'reset-phrase',
|
|
};
|
|
expect(planRateLimitResume(assessment, NOW_MS)).toEqual({
|
|
kind: 'scheduled',
|
|
fireAtMs: NOW_MS + 5 * MINUTE_MS + 30_000,
|
|
});
|
|
});
|
|
|
|
it('uses a conservative 15-minute default without a reset time', () => {
|
|
expect(
|
|
planRateLimitResume(
|
|
{ isRateLimit: true, resetAtMs: null, source: 'status-only' },
|
|
NOW_MS,
|
|
),
|
|
).toEqual({ kind: 'scheduled', fireAtMs: NOW_MS + 15 * MINUTE_MS });
|
|
});
|
|
|
|
it('caps a scheduled resume at 12 hours', () => {
|
|
expect(
|
|
planRateLimitResume(
|
|
{ isRateLimit: true, resetAtMs: NOW_MS + 12 * HOUR_MS, source: 'reset-phrase' },
|
|
NOW_MS,
|
|
),
|
|
).toEqual({ kind: 'scheduled', fireAtMs: NOW_MS + 12 * HOUR_MS });
|
|
});
|
|
|
|
it('requires manual handling for non-rate-limit errors', () => {
|
|
expect(
|
|
planRateLimitResume(
|
|
{ isRateLimit: false, resetAtMs: null, source: 'none' },
|
|
NOW_MS,
|
|
),
|
|
).toEqual({ kind: 'manual', reason: 'not_rate_limited' });
|
|
});
|
|
});
|