11import 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
2323const HARD_PAGE_SIZE_CAP = 100 ;
2424const 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
0 commit comments