Skip to content

Commit 205b1fb

Browse files
fix(paginate): bound cursor loops without dropping empty pages (#48)
* fix(paginate): break loop on empty items with non-null nextToken paginate() in pagination.ts entered an infinite loop when the API returned { items: [], nextToken: 'cursor' }. The loop only checked nextToken !== null, never whether items was empty. Any filtered list command returning zero results would hang indefinitely. Add an early-exit guard: if the page contains zero items, break regardless of nextToken. Includes 5 unit tests covering the bug reproduction, maxItems cap, single-page, and consecutive empty pages. Closes #35 Signed-off-by: Aldo Rizona <aldorizona10@gmail.com> * style(test): reflow pagination.test.ts to satisfy prettier format:check gate Pure mechanical prettier reflow of two vi.fn(async () => ({...})) callbacks in test/pagination.test.ts. No logic change — only line wrapping to satisfy the format:check CI gate (npm run format:check). All other gates already pass. Coverage remains >=80% on all 4 metrics (lines/statements/functions/branches). * fix(paginate): bound cursor loops without dropping empty pages --------- Signed-off-by: Aldo Rizona <aldorizona10@gmail.com>
1 parent 6b90ff4 commit 205b1fb

2 files changed

Lines changed: 88 additions & 2 deletions

File tree

src/lib/pagination.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { describe, expect, it } from 'vitest';
22
import { ApiError } from './errors.js';
3-
import { paginate, validatePaginationFlags, type FetchPage, type Page } from './pagination.js';
3+
import {
4+
MAX_AUTO_PAGES,
5+
paginate,
6+
validatePaginationFlags,
7+
type FetchPage,
8+
type Page,
9+
} from './pagination.js';
410

511
function makePages<T>(pages: Page<T>[]): {
612
fetchPage: FetchPage<T>;
@@ -121,4 +127,55 @@ describe('paginate', () => {
121127
await expect(paginate(fetchPage, { pageSize: 0 })).rejects.toBeInstanceOf(ApiError);
122128
expect(calls).toHaveLength(0);
123129
});
130+
131+
it('continues through empty cursor pages until later data', async () => {
132+
const { fetchPage, calls } = makePages([
133+
{ items: [], nextToken: 'cursor-1' },
134+
{ items: [1], nextToken: null },
135+
]);
136+
137+
const page = await paginate(fetchPage);
138+
139+
expect(page.items).toEqual([1]);
140+
expect(page.nextToken).toBeNull();
141+
expect(calls).toHaveLength(2);
142+
expect(calls[1]!.cursor).toBe('cursor-1');
143+
});
144+
145+
it('rejects when API repeats a non-null cursor without making progress', async () => {
146+
const { fetchPage, calls } = makePages([{ items: [], nextToken: 'cursor-1' }]);
147+
148+
await expect(paginate(fetchPage)).rejects.toMatchObject({
149+
code: 'UNAVAILABLE',
150+
details: expect.objectContaining({ reason: 'repeated_next_token' }),
151+
});
152+
153+
expect(calls).toHaveLength(2);
154+
});
155+
156+
it('rejects when auto-pagination exceeds the page safety cap', async () => {
157+
const calls: Array<{ pageSize: number; cursor: string | undefined }> = [];
158+
const fetchPage: FetchPage<number> = async args => {
159+
calls.push(args);
160+
return {
161+
items: [],
162+
nextToken:
163+
args.cursor === undefined ? 'cursor-1' : `cursor-${Number(args.cursor.slice(7)) + 1}`,
164+
};
165+
};
166+
167+
let thrown: unknown;
168+
try {
169+
await paginate(fetchPage);
170+
} catch (err) {
171+
thrown = err;
172+
}
173+
174+
expect(thrown).toBeInstanceOf(ApiError);
175+
expect(thrown).toMatchObject({
176+
code: 'UNAVAILABLE',
177+
details: expect.objectContaining({ reason: 'max_pages_exceeded' }),
178+
});
179+
expect(calls).toHaveLength(MAX_AUTO_PAGES);
180+
});
124181
});

src/lib/pagination.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { HttpClient } from './http.js';
2-
import { localValidationError } from './errors.js';
2+
import { ApiError, localValidationError } from './errors.js';
33

44
/**
55
* Page shape returned by every list endpoint per
@@ -22,6 +22,7 @@ export interface PaginationFlags {
2222

2323
const HARD_PAGE_SIZE_CAP = 100;
2424
const DEFAULT_PAGE_SIZE = 25;
25+
export const MAX_AUTO_PAGES = 1000;
2526

2627
/**
2728
* Validates and normalizes pagination flags. Per the CLI OpenAPI spec
@@ -91,14 +92,24 @@ export async function paginate<T>(
9192
const items: T[] = [];
9293
let cursor: string | undefined = flags.startingToken;
9394
let lastNextToken: string | null = null;
95+
let pagesFetched = 0;
96+
const seenNextTokens = new Set<string>();
97+
if (cursor !== undefined) seenNextTokens.add(cursor);
9498

9599
while (true) {
96100
const remaining = maxItems !== undefined ? maxItems - items.length : Infinity;
97101
if (remaining <= 0) break;
102+
if (pagesFetched >= MAX_AUTO_PAGES) {
103+
throw paginationSafetyError('max_pages_exceeded', {
104+
maxPages: MAX_AUTO_PAGES,
105+
lastCursor: cursor ?? null,
106+
});
107+
}
98108

99109
const callPageSize = Number.isFinite(remaining) ? Math.min(pageSize, remaining) : pageSize;
100110

101111
const page = await fetchPage({ pageSize: callPageSize, cursor });
112+
pagesFetched += 1;
102113
lastNextToken = page.nextToken;
103114

104115
for (const item of page.items) {
@@ -107,12 +118,30 @@ export async function paginate<T>(
107118
}
108119

109120
if (page.nextToken === null) break;
121+
if (seenNextTokens.has(page.nextToken)) {
122+
throw paginationSafetyError('repeated_next_token', {
123+
cursor: page.nextToken,
124+
pagesFetched,
125+
});
126+
}
127+
seenNextTokens.add(page.nextToken);
110128
cursor = page.nextToken;
111129
}
112130

113131
return { items, nextToken: lastNextToken };
114132
}
115133

134+
function paginationSafetyError(reason: string, details: Record<string, unknown>): ApiError {
135+
return ApiError.fromEnvelope({
136+
code: 'UNAVAILABLE',
137+
message: 'Pagination did not make progress safely.',
138+
nextAction:
139+
'Retry later. If the problem continues, contact TestSprite support with the cursor details.',
140+
requestId: 'local',
141+
details: { reason, ...details },
142+
});
143+
}
144+
116145
/**
117146
* Drop-in helper for commands that take a single page and surface
118147
* the cursor verbatim (no auto-follow). Used when the caller passed

0 commit comments

Comments
 (0)