Skip to content

Commit eeac6c7

Browse files
fix(paginate): bound cursor loops without dropping empty pages
1 parent b7e78c7 commit eeac6c7

3 files changed

Lines changed: 88 additions & 89 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>;
@@ -113,4 +119,55 @@ describe('paginate', () => {
113119
await expect(paginate(fetchPage, { pageSize: 0 })).rejects.toBeInstanceOf(ApiError);
114120
expect(calls).toHaveLength(0);
115121
});
122+
123+
it('continues through empty cursor pages until later data', async () => {
124+
const { fetchPage, calls } = makePages([
125+
{ items: [], nextToken: 'cursor-1' },
126+
{ items: [1], nextToken: null },
127+
]);
128+
129+
const page = await paginate(fetchPage);
130+
131+
expect(page.items).toEqual([1]);
132+
expect(page.nextToken).toBeNull();
133+
expect(calls).toHaveLength(2);
134+
expect(calls[1]!.cursor).toBe('cursor-1');
135+
});
136+
137+
it('rejects when API repeats a non-null cursor without making progress', async () => {
138+
const { fetchPage, calls } = makePages([{ items: [], nextToken: 'cursor-1' }]);
139+
140+
await expect(paginate(fetchPage)).rejects.toMatchObject({
141+
code: 'UNAVAILABLE',
142+
details: expect.objectContaining({ reason: 'repeated_next_token' }),
143+
});
144+
145+
expect(calls).toHaveLength(2);
146+
});
147+
148+
it('rejects when auto-pagination exceeds the page safety cap', async () => {
149+
const calls: Array<{ pageSize: number; cursor: string | undefined }> = [];
150+
const fetchPage: FetchPage<number> = async args => {
151+
calls.push(args);
152+
return {
153+
items: [],
154+
nextToken:
155+
args.cursor === undefined ? 'cursor-1' : `cursor-${Number(args.cursor.slice(7)) + 1}`,
156+
};
157+
};
158+
159+
let thrown: unknown;
160+
try {
161+
await paginate(fetchPage);
162+
} catch (err) {
163+
thrown = err;
164+
}
165+
166+
expect(thrown).toBeInstanceOf(ApiError);
167+
expect(thrown).toMatchObject({
168+
code: 'UNAVAILABLE',
169+
details: expect.objectContaining({ reason: 'max_pages_exceeded' }),
170+
});
171+
expect(calls).toHaveLength(MAX_AUTO_PAGES);
172+
});
116173
});

src/lib/pagination.ts

Lines changed: 30 additions & 5 deletions
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,32 +92,56 @@ 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) {
105116
if (maxItems !== undefined && items.length >= maxItems) break;
106117
items.push(item);
107118
}
108119

109-
// Guard against infinite loop when API returns empty items with non-null nextToken.
110-
// Without this, a filtered list returning zero results hangs indefinitely.
111-
if (page.items.length === 0) break;
112-
113120
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);
114128
cursor = page.nextToken;
115129
}
116130

117131
return { items, nextToken: lastNextToken };
118132
}
119133

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+
120145
/**
121146
* Drop-in helper for commands that take a single page and surface
122147
* the cursor verbatim (no auto-follow). Used when the caller passed

test/pagination.test.ts

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

0 commit comments

Comments
 (0)