Skip to content

Commit ef1667f

Browse files
committed
test(poller): cover enhanced rate-limit protection
1 parent 7889252 commit ef1667f

10 files changed

Lines changed: 258 additions & 170 deletions

REFACTOR-PLAN.md

Lines changed: 0 additions & 157 deletions
This file was deleted.

test/github.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,10 @@ describe('fetchRateLimit', () => {
259259
ok: false,
260260
status: 401,
261261
statusText: 'Unauthorized',
262+
text: vi.fn().mockResolvedValue(''),
263+
headers: {
264+
get: vi.fn().mockReturnValue(null),
265+
},
262266
};
263267
const mockFetch = vi.fn().mockResolvedValue(mockResponse);
264268
vi.stubGlobal('fetch', mockFetch);
@@ -272,6 +276,38 @@ describe('fetchRateLimit', () => {
272276
}
273277
});
274278

279+
it('captures rate limit headers and message for 429 responses', async () => {
280+
const headerMap = new Map([
281+
['x-ratelimit-remaining', '0'],
282+
['x-ratelimit-reset', '1706203600'],
283+
['retry-after', '45'],
284+
]);
285+
286+
const mockResponse = {
287+
ok: false,
288+
status: 429,
289+
statusText: 'Too Many Requests',
290+
text: vi.fn().mockResolvedValue(JSON.stringify({ message: 'Secondary rate limit exceeded' })),
291+
headers: {
292+
get: (name: string) => headerMap.get(name) ?? null,
293+
},
294+
};
295+
const mockFetch = vi.fn().mockResolvedValue(mockResponse);
296+
vi.stubGlobal('fetch', mockFetch);
297+
298+
const result = await fetchRateLimit('test-token');
299+
300+
expect(result.success).toBe(false);
301+
if (!result.success) {
302+
expect(result.error).toContain('HTTP 429');
303+
expect(result.rate_limit?.status).toBe(429);
304+
expect(result.rate_limit?.rate_limit_remaining).toBe(0);
305+
expect(result.rate_limit?.rate_limit_reset).toBe(1706203600);
306+
expect(result.rate_limit?.retry_after_seconds).toBe(45);
307+
expect(result.rate_limit?.message).toContain('Secondary rate limit exceeded');
308+
}
309+
});
310+
275311
it('passes abort signal to fetch', async () => {
276312
// Mock fetch to capture the signal
277313
let capturedSignal: AbortSignal | undefined;

test/output.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ function makeState(overrides: Partial<ReducerState> = {}): ReducerState {
2626
buckets: {},
2727
started_at_ts: '2026-01-25T12:00:00.000Z',
2828
stopped_at_ts: '2026-01-25T12:10:00.000Z',
29+
poller_started_at_ts: null,
2930
interval_seconds: 30,
3031
poll_count: 20,
3132
poll_failures: 0,
33+
secondary_rate_limit_hits: 0,
3234
last_error: null,
3335
...overrides,
3436
};
@@ -346,6 +348,15 @@ describe('generateWarnings', () => {
346348
expect(warnings).toContainEqual(expect.stringContaining('3 anomaly'));
347349
});
348350

351+
it('warns on secondary rate limit hits', () => {
352+
const state = makeState({ secondary_rate_limit_hits: 2 });
353+
const warnings = generateWarnings(state);
354+
355+
expect(warnings).toContainEqual(
356+
expect.stringContaining('Secondary rate limit warning response was received (2 times)'),
357+
);
358+
});
359+
349360
it('warns on multiple window crosses for active buckets', () => {
350361
const baseBucket = {
351362
last_reset: 1706230800,

test/poller/helpers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export function makeBucket(overrides: Partial<BucketState> = {}): BucketState {
1515
last_seen_ts: '2025-01-25T12:00:00Z',
1616
limit: 5000,
1717
remaining: 5000,
18+
first_used: 0,
19+
first_remaining: 5000,
1820
...overrides,
1921
};
2022
}
@@ -28,6 +30,7 @@ export function makeState(buckets: Record<string, BucketState> = {}): ReducerSta
2830
interval_seconds: POLL_INTERVAL_SECONDS,
2931
poll_count: 5,
3032
poll_failures: 0,
33+
secondary_rate_limit_hits: 0,
3134
last_error: null,
3235
};
3336
}

test/poller/loop.test.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55
*/
66

77
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
8-
import { main, createShutdownHandler, isDiagnosticsEnabled, runPollerLoop } from '../../src/poller';
8+
import {
9+
main,
10+
createShutdownHandler,
11+
isDiagnosticsEnabled,
12+
runPollerLoop,
13+
createRateLimitControlState,
14+
} from '../../src/poller';
915
import type { LoopDeps } from '../../src/poller';
1016
import type { ReducerState } from '../../src/types';
1117
import { MAX_LIFETIME_MS } from '../../src/types';
@@ -184,6 +190,10 @@ describe('runPollerLoop', () => {
184190
};
185191
}
186192

193+
function makePollSuccess(state: ReducerState) {
194+
return { success: true as const, state, control_state: createRateLimitControlState() };
195+
}
196+
187197
/** Creates deps that exit via MAX_LIFETIME_MS after `exitAfterNowCalls` calls to `deps.now`. */
188198
function makeTimedDeps(exitAfterNowCalls: number, overrides?: Partial<LoopDeps>): LoopDeps {
189199
let nowCallCount = 0;
@@ -192,7 +202,7 @@ describe('runPollerLoop', () => {
192202
nowCallCount++;
193203
return nowCallCount >= exitAfterNowCalls ? MAX_LIFETIME_MS : 0;
194204
}),
195-
performPoll: vi.fn().mockResolvedValue(makeState()),
205+
performPoll: vi.fn().mockResolvedValue(makePollSuccess(makeState())),
196206
...overrides,
197207
});
198208
}
@@ -248,7 +258,7 @@ describe('runPollerLoop', () => {
248258
const deps = makeTimedDeps(3, {
249259
performPoll: vi.fn().mockImplementation(() => {
250260
pollCallCount++;
251-
if (pollCallCount === 1) return Promise.resolve(makeState());
261+
if (pollCallCount === 1) return Promise.resolve(makePollSuccess(makeState()));
252262
return Promise.reject(new Error('network failure'));
253263
}),
254264
});
@@ -292,7 +302,7 @@ describe('runPollerLoop', () => {
292302
if (nowCallCount <= 3) return nowMs;
293303
return nowMs + MAX_LIFETIME_MS; // triggers lifetime exit
294304
}),
295-
performPoll: vi.fn().mockResolvedValue(burstState),
305+
performPoll: vi.fn().mockResolvedValue(makePollSuccess(burstState)),
296306
});
297307

298308
await runPollerLoop('token', 30, false, deps);

0 commit comments

Comments
 (0)