141 lines
4.5 KiB
TypeScript
141 lines
4.5 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import { withVercelRateLimitRetry } from '@/providers/deployments/vercel.provider.js';
|
|
import { AppError } from '@/utils/errors.js';
|
|
import { ERROR_CODES } from '@insforge/shared-schemas';
|
|
|
|
describe('withVercelRateLimitRetry', () => {
|
|
it('retries on 429 honoring X-RateLimit-Reset (unix seconds)', async () => {
|
|
const reset = Math.floor(Date.now() / 1000) + 1; // ~1s in the future
|
|
let attempts = 0;
|
|
const op = vi.fn(async () => {
|
|
attempts++;
|
|
if (attempts === 1) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const err: any = new Error('429');
|
|
err.isAxiosError = true;
|
|
err.response = { status: 429, headers: { 'x-ratelimit-reset': String(reset) } };
|
|
throw err;
|
|
}
|
|
return { id: 'ok' };
|
|
});
|
|
|
|
const result = await withVercelRateLimitRetry(op, {
|
|
maxRetries: 3,
|
|
baseDelayMs: 100,
|
|
maxDelayMs: 5000,
|
|
jitterMaxMs: 50,
|
|
});
|
|
expect(result).toEqual({ id: 'ok' });
|
|
expect(attempts).toBe(2);
|
|
});
|
|
|
|
it('falls back to exponential backoff when X-RateLimit-Reset is missing', async () => {
|
|
let attempts = 0;
|
|
const op = vi.fn(async () => {
|
|
attempts++;
|
|
if (attempts < 3) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const err: any = new Error('429');
|
|
err.isAxiosError = true;
|
|
err.response = { status: 429, headers: {} };
|
|
throw err;
|
|
}
|
|
return { id: 'ok' };
|
|
});
|
|
|
|
const result = await withVercelRateLimitRetry(op, {
|
|
maxRetries: 3,
|
|
baseDelayMs: 1,
|
|
maxDelayMs: 100,
|
|
jitterMaxMs: 1,
|
|
});
|
|
expect(result).toEqual({ id: 'ok' });
|
|
expect(attempts).toBe(3);
|
|
});
|
|
|
|
it('rethrows non-429 errors immediately', async () => {
|
|
const op = vi.fn(async () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const err: any = new Error('boom');
|
|
err.isAxiosError = true;
|
|
err.response = { status: 500 };
|
|
throw err;
|
|
});
|
|
|
|
await expect(
|
|
withVercelRateLimitRetry(op, { maxRetries: 3, baseDelayMs: 1, maxDelayMs: 1, jitterMaxMs: 1 })
|
|
).rejects.toThrow('boom');
|
|
expect(op).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('throws AppError(429, RATE_LIMITED) after exhausting retries', async () => {
|
|
const op = vi.fn(async () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const err: any = new Error('429');
|
|
err.isAxiosError = true;
|
|
err.response = { status: 429, headers: {} };
|
|
throw err;
|
|
});
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await withVercelRateLimitRetry(op, {
|
|
maxRetries: 2,
|
|
baseDelayMs: 1,
|
|
maxDelayMs: 1,
|
|
jitterMaxMs: 1,
|
|
});
|
|
} catch (err) {
|
|
caught = err;
|
|
}
|
|
expect(caught).toBeInstanceOf(AppError);
|
|
expect((caught as AppError).statusCode).toBe(429);
|
|
expect((caught as AppError).code).toBe(ERROR_CODES.RATE_LIMITED);
|
|
expect(op).toHaveBeenCalledTimes(3); // initial + 2 retries
|
|
});
|
|
});
|
|
|
|
describe('withVercelRateLimitRetry — delay clamping', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('clamps the final delay (base + jitter) at maxDelayMs', async () => {
|
|
// Capture every setTimeout delay the helper requests, then run cb sync so
|
|
// the test does not actually wait. We can then assert the requested
|
|
// delays never exceed maxDelayMs even with maximal jitter.
|
|
const recordedDelays: number[] = [];
|
|
vi.spyOn(global, 'setTimeout').mockImplementation(((
|
|
cb: (...args: unknown[]) => void,
|
|
ms?: number
|
|
) => {
|
|
recordedDelays.push(ms ?? 0);
|
|
cb();
|
|
return 0 as unknown as NodeJS.Timeout;
|
|
}) as unknown as typeof setTimeout);
|
|
// Force jitter to its maximum so it cannot mask the clamp bug.
|
|
vi.spyOn(Math, 'random').mockReturnValue(0.999_999);
|
|
|
|
let attempts = 0;
|
|
const op = vi.fn(async () => {
|
|
attempts++;
|
|
if (attempts < 3) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const err: any = new Error('429');
|
|
err.isAxiosError = true;
|
|
err.response = { status: 429, headers: {} };
|
|
throw err;
|
|
}
|
|
return { id: 'ok' };
|
|
});
|
|
|
|
const opts = { maxRetries: 3, baseDelayMs: 100, maxDelayMs: 200, jitterMaxMs: 1000 };
|
|
await withVercelRateLimitRetry(op, opts);
|
|
|
|
expect(recordedDelays.length).toBeGreaterThan(0);
|
|
for (const d of recordedDelays) {
|
|
expect(d).toBeLessThanOrEqual(opts.maxDelayMs);
|
|
}
|
|
});
|
|
});
|