import Lacuna from '../lacuna'; import { rpcLimitHandler } from './rpc-limit-handler'; import type { ResponseEvent, ResponseHandler } from './responses'; import type { ServerRequest, ServerResponse } from './server'; // A pure unit test: driving the real 1010 path would mean firing 200+ live // requests just to trip the server's per-minute limit, which is exactly what // this suite's session caching and --runInBand exist to avoid. The handler is // a function of the event, so a synthetic event exercises it fully. const request: ServerRequest = { module: 'empire', method: 'get_status', params: {}, addSession: true, }; const slowDown = { code: 1010, message: 'Slow down', data: {} }; const eventFor = ( response: ServerResponse, attempt = 0, retry: () => Promise> = jest.fn() ): ResponseEvent => ({ request, response, attempt, retry }) as ResponseEvent; let lacuna: Lacuna; let handler: ResponseHandler; beforeEach(() => { jest.useFakeTimers(); console.warn = jest.fn(); lacuna = new Lacuna(); handler = rpcLimitHandler(lacuna, { maxRetries: 3, baseDelayMs: 1000, maxDelayMs: 5000 }); }); afterEach(() => { jest.useRealTimers(); }); /** Runs the handler while letting its backoff timer fire immediately. */ async function run(event: ResponseEvent) { const pending = handler(event); await jest.runAllTimersAsync(); return pending; } test('ignores anything that is not an RPC limit error', async () => { const retry = jest.fn(); expect(await run(eventFor({ result: { ok: 1 } }, 0, retry))).toBeUndefined(); expect( await run(eventFor({ error: { code: 1002, message: 'nope', data: {} } }, 0, retry)) ).toBeUndefined(); expect(retry).not.toHaveBeenCalled(); }); test('retries an RPC limit error and returns the retry response', async () => { const recovered = { result: { ok: 1 } }; const retry = jest.fn().mockResolvedValue(recovered); const handled = await run(eventFor({ error: slowDown }, 0, retry)); expect(retry).toHaveBeenCalledTimes(1); expect(handled).toBe(recovered); expect(console.warn).toHaveBeenCalledWith( expect.stringContaining('RPC limit hit on empire/get_status, retrying in 1000ms (1/3)') ); }); test('backs off progressively as attempts climb', async () => { const retry = jest.fn().mockResolvedValue({ result: {} }); for (const attempt of [0, 1, 2]) { await run(eventFor({ error: slowDown }, attempt, retry)); } const logged = (console.warn as jest.Mock).mock.calls.map((c) => c[0] as string); expect(logged[0]).toContain('retrying in 1000ms'); expect(logged[1]).toContain('retrying in 2000ms'); // 1000 * 2^2 = 4000, still under the 5000ms ceiling. expect(logged[2]).toContain('retrying in 4000ms'); }); test('clamps the delay at maxDelayMs on later attempts', async () => { const retry = jest.fn().mockResolvedValue({ result: {} }); await run(eventFor({ error: slowDown }, 2, retry)); expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('retrying in 4000ms')); (console.warn as jest.Mock).mockClear(); handler = rpcLimitHandler(lacuna, { maxRetries: 10, baseDelayMs: 1000, maxDelayMs: 5000 }); await run(eventFor({ error: slowDown }, 5, retry)); expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('retrying in 5000ms')); }); test('gives up once maxRetries is reached, leaving the error in place', async () => { const retry = jest.fn(); const handled = await run(eventFor({ error: slowDown }, 3, retry)); expect(retry).not.toHaveBeenCalled(); expect(handled).toBeUndefined(); expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('after 3 retries - giving up')); }); test('defaults to 5 retries with a 1s base delay', async () => { const retry = jest.fn().mockResolvedValue({ result: {} }); handler = rpcLimitHandler(lacuna); await run(eventFor({ error: slowDown }, 4, retry)); expect(retry).toHaveBeenCalledTimes(1); expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('(5/5)')); await run(eventFor({ error: slowDown }, 5, retry)); expect(retry).toHaveBeenCalledTimes(1); });