Skip to content

Commit 1cc6c32

Browse files
committed
fix: better fetch timeout handling
1 parent 1ca54f5 commit 1cc6c32

1 file changed

Lines changed: 107 additions & 103 deletions

File tree

src/utils/newFetch/HTTPClient.js

Lines changed: 107 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)