@@ -38,8 +38,6 @@ export default class HttpClient {
3838
3939 abortController ;
4040
41- timeout ;
42-
4341 onError ;
4442
4543 onProgressUpdate ;
@@ -70,24 +68,41 @@ export default class HttpClient {
7068 } ;
7169
7270 fetchNext = async ( query , results ) => {
73- const signal = this . abortController ?. signal || null ;
74- // Clear old timeout
75- this . clearTimeout ( ) ;
76- // Create new timeout for next fetch
77- this . createTimeout ( ) ;
78-
79- return fetch ( query , { signal } )
80- . then ( ( response ) => response . json ( ) )
81- . then ( async ( response ) => {
82- const combinedResults = [ ...results , ...response . results ] ;
83- if ( this . onProgressUpdate ) {
84- this . onProgressUpdate ( combinedResults . length , response . count ) ;
85- }
86- if ( response . next ) {
87- return this . fetchNext ( response . next , combinedResults ) ;
88- }
89- return combinedResults ;
90- } ) ;
71+ // Use an externally provided AbortController when present (e.g. SearchBar
72+ // suggestions), otherwise create a per-request one so each paginated fetch
73+ // owns its timeout and can't be aborted by a sibling request finishing.
74+ const abortController = this . abortController || new AbortController ( ) ;
75+ const { signal } = abortController ;
76+ const timeoutId = setTimeout (
77+ ( ) => abortController . abort ( ) ,
78+ this . timeoutTimer
79+ ) ;
80+
81+ try {
82+ const response = await fetch ( query , { signal } ) ;
83+ const json = await response . json ( ) ;
84+ const combinedResults = [ ...results , ...json . results ] ;
85+ if ( this . onProgressUpdate ) {
86+ this . onProgressUpdate ( combinedResults . length , json . count ) ;
87+ }
88+ if ( json . next ) {
89+ return await this . fetchNext ( json . next , combinedResults ) ;
90+ }
91+ return combinedResults ;
92+ } catch ( e ) {
93+ // Let already-classified errors bubble to handleFetch untouched.
94+ if ( e instanceof APIFetchError ) {
95+ throw e ;
96+ }
97+ // iOS/Safari throws TypeError("Load failed") instead of AbortError when a
98+ // fetch is cancelled, so trust signal.aborted as the source of truth.
99+ if ( e . name === 'AbortError' || signal . aborted ) {
100+ throw new AbortAPIError ( `Error ${ query } fetch aborted` , e ) ;
101+ }
102+ throw e ;
103+ } finally {
104+ clearTimeout ( timeoutId ) ;
105+ }
91106 } ;
92107
93108 handleServiceMapResults = async ( response , type ) => {
@@ -142,72 +157,69 @@ export default class HttpClient {
142157 } ;
143158
144159 handleFetch = async ( endpoint , url , options = { } , type ) => {
145- if ( ! this . abortController ) {
146- this . abortController = new AbortController ( ) ;
147- }
148160 this . status = 'fetching' ;
149161
150- const signal = this . abortController ?. signal || null ;
151-
152162 // Since we do not send any POST data to server we expect all fetches to be GET
153163 // and utilize search parameters for sending required data
154164 if ( typeof options !== 'object' ) {
155165 this . throwAPIError (
156166 "Invalid options given to HTTPClient's handleFetch method"
157167 ) ;
158168 }
159- // Create fetch promise
160- const promise = fetch ( `${ url } ` , { ...options , signal } ) ;
161169
162- // Create timeout for aborting fetch
163- if ( ! this . timeout ) {
164- this . createTimeout ( ) ;
165- }
170+ // Use an externally provided AbortController when present (e.g. SearchBar
171+ // suggestions), otherwise create a per-request one with its own timeout so
172+ // concurrent requests don't share an abort signal or cancel one another.
173+ const abortController = this . abortController || new AbortController ( ) ;
174+ const { signal } = abortController ;
175+ const timeoutId = setTimeout (
176+ ( ) => abortController . abort ( ) ,
177+ this . timeoutTimer
178+ ) ;
179+
180+ try {
181+ const response = await fetch ( `${ url } ` , { ...options , signal } ) ;
166182
167- // Preform fetch
168- return promise
169- . then ( ( response ) => {
170- if ( type === 'post' ) return response ;
183+ let data ;
184+ if ( type === 'post' ) {
185+ data = response ;
186+ } else {
171187 // Surface HTTP errors with a clean message instead of letting
172188 // response.json() produce a misleading SyntaxError on error pages.
173189 if ( ! response . ok ) {
174190 throw new APIFetchError (
175191 `Error while fetching ${ endpoint } : HTTP ${ response . status } ${ response . statusText } `
176192 ) ;
177193 }
178- return response . json ( ) ;
179- } )
180- . then ( async ( data ) => {
181- const results = await this . handleResults ( data , type ) ;
182- this . clearTimeout ( ) ;
183- this . status = 'done' ;
184- return results ;
185- } )
186- . catch ( ( e ) => {
187- // Already classified upstream (e.g. non-ok response) — don't rewrap.
188- if ( e instanceof APIFetchError ) {
189- this . status = 'error' ;
190- this . clearTimeout ( ) ;
191- if ( this . onError ) this . onError ( e ) ;
192- throw e ;
193- }
194+ data = await response . json ( ) ;
195+ }
194196
195- // iOS/Safari throws TypeError("Load failed") instead of DOMException("AbortError")
196- // when a fetch is cancelled via AbortSignal. Check signal.aborted as the
197- // authoritative source of truth so both browsers are handled uniformly.
198- if ( e . name === 'AbortError' || signal ?. aborted ) {
199- this . throwAPIError (
200- `Error ${ endpoint } fetch aborted` ,
201- e ,
202- AbortAPIError
203- ) ;
204- } else {
205- this . throwAPIError (
206- `Error while fetching ${ endpoint } : ${ e . message } ` ,
207- e
208- ) ;
209- }
210- } ) ;
197+ const results = await this . handleResults ( data , type ) ;
198+ this . status = 'done' ;
199+ return results ;
200+ } catch ( e ) {
201+ // Already classified upstream (e.g. non-ok response, fetchNext abort) —
202+ // don't rewrap.
203+ if ( e instanceof APIFetchError ) {
204+ this . status = 'error' ;
205+ if ( this . onError ) this . onError ( e ) ;
206+ throw e ;
207+ }
208+
209+ // iOS/Safari throws TypeError("Load failed") instead of DOMException("AbortError")
210+ // when a fetch is cancelled via AbortSignal. Check signal.aborted as the
211+ // authoritative source of truth so both browsers are handled uniformly.
212+ if ( e . name === 'AbortError' || signal ?. aborted ) {
213+ this . throwAPIError ( `Error ${ endpoint } fetch aborted` , e , AbortAPIError ) ;
214+ } else {
215+ this . throwAPIError ( `Error while fetching ${ endpoint } : ${ e . message } ` , e ) ;
216+ }
217+
218+ // throwAPIError always throws; this satisfies consistent-return.
219+ return undefined ;
220+ } finally {
221+ clearTimeout ( timeoutId ) ;
222+ }
211223 } ;
212224
213225 // Create a POST fetch request to given endpoint with given data.
@@ -276,7 +288,7 @@ export default class HttpClient {
276288 ) ;
277289 } ;
278290
279- getConcurrent = async ( endpoint , options ) => {
291+ getConcurrent = async ( endpoint , options , concurrencyLimit = 30 ) => {
280292 if ( ! options ?. page_size ) {
281293 throw new APIFetchError (
282294 'Invalid page_size provided for concurrent search method'
@@ -300,25 +312,40 @@ export default class HttpClient {
300312 this . onProgressUpdate ( firstPage . data . length , totalCount ) ;
301313 }
302314
303- // Create promises for remaining pages (2..N)
304- const promises = [ ] ;
305- for ( let i = 2 ; i <= numberOfPages ; i += 1 ) {
306- const promise = this . getSinglePage ( endpoint , { ...options , page : i } ) ;
307- promises . push (
308- promise . then ( ( res ) => {
309- this . clearTimeout ( ) ;
310- return res ?. data ?? [ ] ;
311- } )
315+ // Fetch remaining pages (2..N) in batches so we don't open hundreds of
316+ // simultaneous connections, which the API can reject under load.
317+ const results = [ ...firstPage . data ] ;
318+ for (
319+ let batchStart = 2 ;
320+ batchStart <= numberOfPages ;
321+ batchStart += concurrencyLimit
322+ ) {
323+ const batchEnd = Math . min (
324+ batchStart + concurrencyLimit - 1 ,
325+ numberOfPages
312326 ) ;
327+ const batchPromises = [ ] ;
328+ for ( let page = batchStart ; page <= batchEnd ; page += 1 ) {
329+ batchPromises . push (
330+ this . getSinglePage ( endpoint , { ...options , page } ) . then (
331+ ( res ) => res ?. data ?? [ ]
332+ )
333+ ) ;
334+ }
335+ // Batches run sequentially on purpose to cap concurrent requests.
336+ // eslint-disable-next-line no-await-in-loop
337+ const batchData = await Promise . all ( batchPromises ) ;
338+ results . push ( ...batchData . flat ( ) ) ;
339+ if ( this . onProgressUpdate ) {
340+ this . onProgressUpdate ( results . length , totalCount ) ;
341+ }
313342 }
314343
315- const otherPagesData = await Promise . all ( promises ) ;
316- return [ ...firstPage . data , ...otherPagesData . flat ( ) ] ;
344+ return results ;
317345 } ;
318346
319347 throwAPIError = ( msg , e , ErrorClass = APIFetchError ) => {
320348 this . status = 'error' ;
321- this . clearTimeout ( ) ;
322349 if ( this . onError ) {
323350 this . onError ( e ) ;
324351 }
@@ -327,29 +354,6 @@ export default class HttpClient {
327354
328355 getStatus = ( ) => this . status ;
329356
330- abort = ( ) => {
331- if ( ! this . abortController ?. abort ) {
332- throw new APIFetchError (
333- 'Invalid AbortController when attempting to abort fetch'
334- ) ;
335- }
336- this . clearTimeout ( ) ;
337- this . abortController . abort ( ) ;
338- } ;
339-
340- createTimeout = ( ) => {
341- this . timeout = setTimeout ( ( ) => {
342- this . abort ( ) ;
343- } , this . timeoutTimer ) ;
344- } ;
345-
346- clearTimeout = ( ) => {
347- if ( this . timeout ) {
348- clearTimeout ( this . timeout ) ;
349- this . timeout = null ;
350- }
351- } ;
352-
353357 setOnProgressUpdate = ( onProgressUpdate ) => {
354358 if ( typeof onProgressUpdate !== 'function' ) {
355359 throw new APIFetchError (
0 commit comments