Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
148 changes: 148 additions & 0 deletions scripts/test/rate_limit_utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Unit tests for rate limit utilities
*/

import { checkRateLimit, processBatch } from '../rate_limit_utils';

describe('processBatch', () => {
beforeEach(() => {
jest.spyOn(console, 'log').mockImplementation(() => {});
});

afterEach(() => {
jest.restoreAllMocks();
});

it('processes every item and preserves input order across batches', async () => {
const items = [1, 2, 3, 4, 5];
const processor = jest.fn(async (n: number) => n * 2);

const results = await processBatch(items, 2, processor, 0);

expect(results).toEqual([2, 4, 6, 8, 10]);
expect(processor).toHaveBeenCalledTimes(5);
});

it('returns an empty array and never calls the processor for empty input', async () => {
const processor = jest.fn(async (n: number) => n);

const results = await processBatch([], 3, processor, 0);

expect(results).toEqual([]);
expect(processor).not.toHaveBeenCalled();
});

it('handles a batch size larger than the number of items (single batch)', async () => {
const items = ['a', 'b'];
const processor = jest.fn(async (s: string) => s.toUpperCase());

const results = await processBatch(items, 10, processor, 0);

expect(results).toEqual(['A', 'B']);
expect(processor).toHaveBeenCalledTimes(2);
});

it('processes items within a batch in parallel', async () => {
const order: string[] = [];
const items = [30, 10, 20];
// Lower numbers resolve sooner; parallel execution within the batch means
// completion order follows the delay, not the input order.
const processor = (n: number) =>
new Promise<number>((resolve) => {
setTimeout(() => {
order.push(`done-${n}`);
resolve(n);
}, n);
});

const results = await processBatch(items, 3, processor, 0);

// Results stay in input order...
expect(results).toEqual([30, 10, 20]);
// ...but they completed in delay order, proving parallel execution.
expect(order).toEqual(['done-10', 'done-20', 'done-30']);
});
});

describe('checkRateLimit', () => {
beforeEach(() => {
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
jest.restoreAllMocks();
});

// The module checks the live rate limit only on every 10th call. Ten
// consecutive calls always span exactly one multiple of ten, so the API is
// queried exactly once per helper invocation regardless of prior state.
function callTenTimes(client: any): Promise<void[]> {
return Promise.all(
Array.from({ length: 10 }, () => checkRateLimit(client))
);
}

function makeClient(remaining: number, resetEpochSeconds: number) {
return {
rateLimit: {
get: jest.fn().mockResolvedValue({
data: { rate: { remaining, reset: resetEpochSeconds } },
}),
},
};
}

it('queries the rate limit once per ten calls', async () => {
const client = makeClient(5000, Math.floor(Date.now() / 1000) + 60);

await callTenTimes(client);

expect(client.rateLimit.get).toHaveBeenCalledTimes(1);
});

it('does not pause when remaining requests are above the threshold', async () => {
const client = makeClient(5000, Math.floor(Date.now() / 1000) + 60);
const setTimeoutSpy = jest
.spyOn(global, 'setTimeout')
.mockImplementation(((cb: () => void) => {
cb();
return 0 as unknown as NodeJS.Timeout;
}) as unknown as typeof setTimeout);

await callTenTimes(client);

// No sleep should be scheduled when we are well under the limit.
expect(setTimeoutSpy).not.toHaveBeenCalled();
});

it('pauses until reset when remaining requests are below the threshold', async () => {
const resetEpochSeconds = Math.floor(Date.now() / 1000) + 60;
const client = makeClient(10, resetEpochSeconds);
const delays: number[] = [];
jest
.spyOn(global, 'setTimeout')
.mockImplementation(((cb: () => void, ms?: number) => {
delays.push(ms ?? 0);
cb();
return 0 as unknown as NodeJS.Timeout;
}) as unknown as typeof setTimeout);

await callTenTimes(client);

expect(delays).toHaveLength(1);
// Waits until reset plus a 1s buffer; allow slack for clock drift in-test.
expect(delays[0]).toBeGreaterThan(0);
});

it('does not throw when the rate limit lookup fails', async () => {
const client = {
rateLimit: {
get: jest.fn().mockRejectedValue(new Error('network down')),
},
};

await expect(callTenTimes(client)).resolves.toBeDefined();
expect(client.rateLimit.get).toHaveBeenCalledTimes(1);
});
});
167 changes: 167 additions & 0 deletions scripts/test/retry_utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* Unit tests for retry utilities
*/

import { retryWithBackoff, RetryOptions } from '../retry_utils';

// Options that make sleeps effectively instant for fast, deterministic tests.
const fastOpts: RetryOptions = { baseDelay: 0, maxDelay: 0 };

describe('retryWithBackoff', () => {
beforeEach(() => {
// Silence the informational logging the function emits during retries.
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
jest.restoreAllMocks();
});

describe('success paths', () => {
it('returns the result without retrying when fn succeeds on first attempt', async () => {
const fn = jest.fn().mockResolvedValue('ok');

await expect(retryWithBackoff(fn, fastOpts)).resolves.toBe('ok');
expect(fn).toHaveBeenCalledTimes(1);
});

it('retries on a retryable error and resolves once fn succeeds', async () => {
const fn = jest
.fn()
.mockRejectedValueOnce(new Error('ThrottlingException: slow down'))
.mockResolvedValueOnce('done');

await expect(retryWithBackoff(fn, fastOpts)).resolves.toBe('done');
expect(fn).toHaveBeenCalledTimes(2);
});

it('preserves the resolved value type', async () => {
const value = { id: 42, name: 'issue' };
const fn = jest.fn().mockResolvedValue(value);

await expect(retryWithBackoff(fn, fastOpts)).resolves.toEqual(value);
});
});

describe('non-retryable errors', () => {
it('throws immediately without retrying when the error is not retryable', async () => {
const fn = jest.fn().mockRejectedValue(new Error('ValidationError'));

await expect(retryWithBackoff(fn, fastOpts)).rejects.toThrow('ValidationError');
expect(fn).toHaveBeenCalledTimes(1);
});
});

describe('retry exhaustion', () => {
it('throws the last error after exhausting the default 3 retries (4 attempts total)', async () => {
const fn = jest.fn().mockRejectedValue(new Error('ThrottlingException'));

await expect(retryWithBackoff(fn, fastOpts)).rejects.toThrow('ThrottlingException');
expect(fn).toHaveBeenCalledTimes(4);
});

it('respects a custom maxRetries value', async () => {
const fn = jest.fn().mockRejectedValue(new Error('ServiceUnavailable'));

await expect(
retryWithBackoff(fn, { ...fastOpts, maxRetries: 2 })
).rejects.toThrow('ServiceUnavailable');
expect(fn).toHaveBeenCalledTimes(3); // initial attempt + 2 retries
});

it('throws the most recent error instance', async () => {
const first = new Error('ThrottlingException #1');
const last = new Error('ThrottlingException #2');
const fn = jest
.fn()
.mockRejectedValueOnce(first)
.mockRejectedValueOnce(last);

await expect(
retryWithBackoff(fn, { ...fastOpts, maxRetries: 1 })
).rejects.toBe(last);
});
});

describe('retryable error detection', () => {
it('detects a retryable error via error.code', async () => {
const err: any = new Error('socket hang up');
err.code = 'ECONNRESET';
const fn = jest.fn().mockRejectedValueOnce(err).mockResolvedValueOnce('ok');

await expect(retryWithBackoff(fn, fastOpts)).resolves.toBe('ok');
expect(fn).toHaveBeenCalledTimes(2);
});

it('detects a retryable error via error.status', async () => {
const err: any = new Error('boom');
err.status = 'ETIMEDOUT';
const fn = jest.fn().mockRejectedValueOnce(err).mockResolvedValueOnce('ok');

await expect(retryWithBackoff(fn, fastOpts)).resolves.toBe('ok');
expect(fn).toHaveBeenCalledTimes(2);
});

it('detects a retryable error via error.name', async () => {
const err: any = new Error('upstream failure');
err.name = 'ServiceUnavailable';
const fn = jest.fn().mockRejectedValueOnce(err).mockResolvedValueOnce('ok');

await expect(retryWithBackoff(fn, fastOpts)).resolves.toBe('ok');
expect(fn).toHaveBeenCalledTimes(2);
});

it('honors a custom retryableErrors list', async () => {
const fn = jest
.fn()
.mockRejectedValueOnce(new Error('CustomTransient'))
.mockResolvedValueOnce('ok');

await expect(
retryWithBackoff(fn, { ...fastOpts, retryableErrors: ['CustomTransient'] })
).resolves.toBe('ok');
expect(fn).toHaveBeenCalledTimes(2);
});

it('treats an error not in a custom list as non-retryable', async () => {
const fn = jest.fn().mockRejectedValue(new Error('ThrottlingException'));

// ThrottlingException is a default, but a custom list overrides defaults.
await expect(
retryWithBackoff(fn, { ...fastOpts, retryableErrors: ['SomethingElse'] })
).rejects.toThrow('ThrottlingException');
expect(fn).toHaveBeenCalledTimes(1);
});
});

describe('exponential backoff timing', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it('uses exponential delays capped at maxDelay', async () => {
const delays: number[] = [];
// Capture each requested delay and invoke the callback synchronously so
// the retry loop proceeds without real waiting.
jest
.spyOn(global, 'setTimeout')
.mockImplementation(((cb: () => void, ms?: number) => {
delays.push(ms ?? 0);
cb();
return 0 as unknown as NodeJS.Timeout;
}) as unknown as typeof setTimeout);

const fn = jest.fn().mockRejectedValue(new Error('ThrottlingException'));

await expect(
retryWithBackoff(fn, { maxRetries: 4, baseDelay: 100, maxDelay: 500 })
).rejects.toThrow('ThrottlingException');

// Delays after attempts 0..3 (the final attempt does not sleep):
// 100*2^0=100, 100*2^1=200, 100*2^2=400, 100*2^3=800 -> capped to 500
expect(delays).toEqual([100, 200, 400, 500]);
expect(fn).toHaveBeenCalledTimes(5);
});
});
});