90 lines
4.0 KiB
JavaScript
90 lines
4.0 KiB
JavaScript
const { createLimiter, createCircuitBreaker, API_BUDGETS } = require('../../src/utils/rateLimiter');
|
|
|
|
describe('createLimiter', () => {
|
|
test('first N tokens within capacity resolve immediately', async () => {
|
|
const limiter = createLimiter({ tokensPerInterval: 3, interval: 60_000 });
|
|
const start = Date.now();
|
|
expect(await limiter.waitForToken()).toBe(true);
|
|
expect(await limiter.waitForToken()).toBe(true);
|
|
expect(await limiter.waitForToken()).toBe(true);
|
|
expect(Date.now() - start).toBeLessThan(100);
|
|
});
|
|
|
|
test('fourth token in a small bucket waits for refill', async () => {
|
|
const limiter = createLimiter({ tokensPerInterval: 2, interval: 200 });
|
|
expect(await limiter.waitForToken()).toBe(true);
|
|
expect(await limiter.waitForToken()).toBe(true);
|
|
const start = Date.now();
|
|
expect(await limiter.waitForToken(2_000)).toBe(true);
|
|
const elapsed = Date.now() - start;
|
|
// Should take roughly interval/tokens = 100ms before refill yields one.
|
|
expect(elapsed).toBeGreaterThan(50);
|
|
expect(elapsed).toBeLessThan(500);
|
|
});
|
|
|
|
test('timeout returns false (does not throw) so caller can proceed', async () => {
|
|
const limiter = createLimiter({ tokensPerInterval: 1, interval: 60_000 });
|
|
expect(await limiter.waitForToken()).toBe(true); // consume the one token
|
|
const result = await limiter.waitForToken(120); // wait briefly, then bail
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
test('throws on invalid config', () => {
|
|
expect(() => createLimiter({ tokensPerInterval: 0, interval: 1000 })).toThrow();
|
|
expect(() => createLimiter({ tokensPerInterval: 1, interval: 0 })).toThrow();
|
|
});
|
|
});
|
|
|
|
describe('createCircuitBreaker', () => {
|
|
test('closed initially; success resets failure count', async () => {
|
|
const cb = createCircuitBreaker({ failureThreshold: 2, resetTimeout: 60_000 });
|
|
const result = await cb.call(async () => 'ok');
|
|
expect(result).toBe('ok');
|
|
expect(cb.snapshot().state).toBe('closed');
|
|
});
|
|
|
|
test('opens after threshold failures and rejects further calls', async () => {
|
|
const cb = createCircuitBreaker({ failureThreshold: 2, resetTimeout: 60_000 });
|
|
await expect(cb.call(async () => { throw new Error('boom'); })).rejects.toThrow('boom');
|
|
await expect(cb.call(async () => { throw new Error('boom'); })).rejects.toThrow('boom');
|
|
expect(cb.snapshot().state).toBe('open');
|
|
await expect(cb.call(async () => 'should never run')).rejects.toMatchObject({ code: 'CIRCUIT_OPEN' });
|
|
});
|
|
|
|
test('transitions to half-open after resetTimeout, closes on success', async () => {
|
|
const cb = createCircuitBreaker({ failureThreshold: 1, resetTimeout: 50 });
|
|
await expect(cb.call(async () => { throw new Error('x'); })).rejects.toThrow();
|
|
expect(cb.snapshot().state).toBe('open');
|
|
await new Promise((r) => setTimeout(r, 80));
|
|
// Reading the snapshot triggers the transition check.
|
|
expect(cb.snapshot().state).toBe('half_open');
|
|
const result = await cb.call(async () => 'recovered');
|
|
expect(result).toBe('recovered');
|
|
expect(cb.snapshot().state).toBe('closed');
|
|
});
|
|
|
|
test('half-open failure re-opens the circuit immediately', async () => {
|
|
const cb = createCircuitBreaker({ failureThreshold: 1, resetTimeout: 30 });
|
|
await expect(cb.call(async () => { throw new Error('first'); })).rejects.toThrow();
|
|
await new Promise((r) => setTimeout(r, 60));
|
|
await expect(cb.call(async () => { throw new Error('second'); })).rejects.toThrow('second');
|
|
expect(cb.snapshot().state).toBe('open');
|
|
});
|
|
});
|
|
|
|
describe('API_BUDGETS', () => {
|
|
test('contains the five named upstreams', () => {
|
|
expect(API_BUDGETS).toMatchObject({
|
|
sharpApi: { tokensPerInterval: 10, interval: 60_000 },
|
|
espn: { tokensPerInterval: 2, interval: 60_000 },
|
|
mlbStats: { tokensPerInterval: 2, interval: 60_000 },
|
|
oddsPapi: { tokensPerInterval: 5, interval: 60_000 },
|
|
openRouter: { tokensPerInterval: 15, interval: 60_000 },
|
|
});
|
|
});
|
|
|
|
test('is frozen — adapters cannot mutate it accidentally', () => {
|
|
expect(Object.isFrozen(API_BUDGETS)).toBe(true);
|
|
});
|
|
});
|