8000 feat(http): Add timeout option to HTTP requests (#57194) · angular/angular@c4cffe2 · GitHub
[go: up one dir, main page]

Skip to content

Commit c4cffe2

Browse files
wartabthePunderWoman
authored andcommitted
feat(http): Add timeout option to HTTP requests (#57194)
Add timeout option to both XHR and fetch backends. PR Close #57194
1 parent 55fa38a commit c4cffe2

File tree

9 files changed

+161
-30
lines changed

9 files changed

+161
-30
lines changed

adev/src/content/guide/http/making-requests.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,19 +198,39 @@ Each `HttpEvent` reported in the event stream has a `type` which distinguishes w
198198

199199
## Handling request failure
200200

201-
There are two ways an HTTP request can fail:
201+
There are three ways an HTTP request can fail:
202202

203203
* A network or connection error can prevent the request from reaching the backend server.
204+
* A request didn't respond in time when the timeout option was set.
204205
* The backend can receive the request but fail to process it, and return an error response.
205206

206-
`HttpClient` captures both kinds of errors in an `HttpErrorResponse` which it returns through the `Observable`'s error channel. Network errors have a `status` code of `0` and an `error` which is an instance of [`ProgressEvent`](https://developer.mozilla.org/docs/Web/API/ProgressEvent). Backend errors have the failing `status` code returned by the backend, and the error response as the `error`. Inspect the response to identify the error's cause and the appropriate action to handle the error.
207+
`HttpClient` captures all of the above kinds of errors in an `HttpErrorResponse` which it returns through the `Observable`'s error channel. Network and timeout errors have a `status` code of `0` and an `error` which is an instance of [`ProgressEvent`](https://developer.mozilla.org/docs/Web/API/ProgressEvent). Backend errors have the failing `status` code returned by the backend, and the error response as the `error`. Inspect the response to identify the error's cause and the appropriate action to handle the error.
207208

208209
The [RxJS library](https://rxjs.dev/) offers several operators which can be useful for error handling.
209210

210211
You can use the `catchError` operator to transform an error response into a value for the UI. This value can tell the UI to display an error page or value, and capture the error's cause if necessary.
211212

212213
Sometimes transient errors such as network interruptions can cause a request to fail unexpectedly, and simply retrying the request will allow it to succeed. RxJS provides several *retry* operators which automatically re-subscribe to a failed `Observable` under certain conditions. For example, the `retry()` operator will automatically attempt to re-subscribe a specified number of times.
213214

215+
### Timeouts
216+
217+
To set a timeout for a request, you can set the `timeout` option to a number of milliseconds along other request options. If the backend request does not complete within the specified time, the request will be aborted and an error will be emitted.
218+
219+
NOTE: The timeout will only apply to the backend HTTP request itself. It is not a timeout for the entire request handling chain. Therefore, this option is not affected by any delay introduced by interceptors.
220+
221+
<docs-code language="ts">
222+
http.get('/api/config', {
223+
timeout: 3000,
224+
}).subscribe({
225+
next: config => {
226+
console.log('Config fetched successfully:', config);
227+
},
228+
error: err => {
229+
// If the request times out, an error will have been emitted.
230+
}
231+
});
232+
</docs-code>
233+
214234
## Http `Observable`s
215235

216236
Each request method on `HttpClient` constructs and returns an `Observable` of the requested response type. Understanding how these `Observable`s work is important when using `HttpClient`.

goldens/public-api/common/http/index.api.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1938,6 +1938,7 @@ export class HttpRequest<T> {
19381938
transferCache?: {
19391939
includeHeaders?: string[];
19401940
} | boolean;
1941+
timeout?: number;
19411942
});
19421943
constructor(method: 'DELETE' | 'JSONP' | 'OPTIONS', url: string, init?: {
19431944
headers?: HttpHeaders;
@@ -1949,6 +1950,7 @@ export class HttpRequest<T> {
19491950
keepalive?: boolean;
19501951
priority?: RequestPriority;
19511952
cache?: RequestCache;
1953+
timeout?: number;
19521954
});
19531955
constructor(method: 'POST', url: string, body: T | null, init?: {
19541956
headers?: HttpHeaders;
@@ -1963,6 +1965,7 @@ export class HttpRequest<T> {
19631965
transferCache?: {
19641966
includeHeaders?: string[];
19651967
} | boolean;
1968+
timeout?: number;
19661969
});
19671970
constructor(method: 'PUT' | 'PATCH', url: string, body: T | null, init?: {
19681971
headers?: HttpHeaders;
@@ -1974,6 +1977,7 @@ export class HttpRequest<T> {
19741977
keepalive?: boolean;
19751978
priority?: RequestPriority;
19761979
cache?: RequestCache;
1980+
timeout?: number;
19771981
});
19781982
constructor(method: string, url: string, body: T | null, init?: {
19791983
headers?: HttpHeaders;
@@ -1988,6 +1992,7 @@ export class HttpRequest<T> {
19881992
transferCache?: {
19891993
includeHeaders?: string[];
19901994
} | boolean;
1995+
timeout?: number;
19911996
});
19921997
readonly body: T | null;
19931998
readonly cache: RequestCache;
@@ -2007,6 +2012,7 @@ export class HttpRequest<T> {
20072012
transferCache?: {
20082013
includeHeaders?: string[];
20092014
} | boolean;
2015+
timeout?: number;
20102016
body?: T | null;
20112017
method?: string;
20122018
url?: string;
@@ -2031,6 +2037,7 @@ export class HttpRequest<T> {
20312037
transferCache?: {
20322038
includeHeaders?: string[];
20332039
} | boolean;
2040+
timeout?: number;
20342041
body?: V | null;
20352042
method?: string;
20362043
url?: string;
@@ -2051,6 +2058,7 @@ export class HttpRequest<T> {
20512058
readonly reportProgress: boolean;
20522059
readonly responseType: 'arraybuffer' | 'blob' | 'json' | 'text';
20532060
serializeBody(): ArrayBuffer | Blob | FormData | URLSearchParams | string | null;
2061+
readonly timeout?: number;
20542062
readonly transferCache?: {
20552063
includeHeaders?: string[];
20562064
} | boolean;

packages/common/http/src/fetch.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,30 @@ export class FetchBackend implements HttpBackend {
8585
handle(request: HttpRequest<any>): Observable<HttpEvent<any>> {
8686
return new Observable((observer) => {
8787
const aborter = new AbortController();
88+
8889
this.doRequest(request, aborter.signal, observer).then(noop, (error) =>
8990
observer.error(new HttpErrorResponse({error})),
9091
);
91-
return () => aborter.abort();
92+
93+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
94+
if (request.timeout) {
95+
// TODO: Replace with AbortSignal.any([aborter.signal, AbortSignal.timeout(request.timeout)])
96+
// when AbortSignal.any support is Baseline widely available (NET nov. 2026)
97+
timeoutId = this.ngZone.runOutsideAngular(() =>
98+
setTimeout(() => {
99+
if (!aborter.signal.aborted) {
100+
aborter.abort(new DOMException('signal timed out', 'TimeoutError'));
101+
}
102+
}, request.timeout),
103+
);
104+
}
105+
106+
return () => {
107+
if (timeoutId !== undefined) {
108+
clearTimeout(timeoutId);
109+
}
110+
aborter.abort();
111+
};
92112
});
93113
}
94114

packages/common/http/src/request.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ interface HttpRequestInit {
2626
keepalive?: boolean;
2727
priority?: RequestPriority;
2828
cache?: RequestCache;
29+
timeout?: number;
2930
}
3031

3132
/**
@@ -217,6 +218,11 @@ export class HttpRequest<T> {
217218
*/
218219
readonly transferCache?: {includeHeaders?: string[]} | boolean;
219220

221+
/**
222+
* The timeout for the backend HTTP request in ms.
223+
*/
224+
readonly timeout?: number;
225+
220226
constructor(
221227
method: 'GET' | 'HEAD',
222228
url: string,
@@ -239,6 +245,7 @@ export class HttpRequest<T> {
239245
* particular request
240246
*/
241247
transferCache?: {includeHeaders?: string[]} | boolean;
248+
timeout?: number;
242249
},
243250
);
244251
constructor(
@@ -254,6 +261,7 @@ export class HttpRequest<T> {
254261
keepalive?: boolean;
255262
priority?: RequestPriority;
256263
cache?: RequestCache;
264+
timeout?: number;
257265
},
258266
);
259267
constructor(
@@ -279,6 +287,7 @@ export class HttpRequest<T> {
279287
* particular request
280288 F438
*/
281289
transferCache?: {includeHeaders?: string[]} | boolean;
290+
timeout?: number;
282291
},
283292
);
284293
constructor(
@@ -295,6 +304,7 @@ export class HttpRequest<T> {
295304
keepalive?: boolean;
296305
priority?: RequestPriority;
297306
cache?: RequestCache;
307+
timeout?: number;
298308
},
299309
);
300310
constructor(
@@ -320,6 +330,7 @@ export class HttpRequest<T> {
320330
* particular request
321331
*/
322332
transferCache?: {includeHeaders?: string[]} | boolean;
333+
timeout?: number;
323334
},
324335
);
325336
constructor(
@@ -338,6 +349,7 @@ export class HttpRequest<T> {
338349
priority?: RequestPriority;
339350
cache?: RequestCache;
340351
transferCache?: {includeHeaders?: string[]} | boolean;
352+
timeout?: number;
341353
}
342354
| null,
343355
fourth?: {
@@ -351,6 +363,7 @@ export class HttpRequest<T> {
351363
priority?: RequestPriority;
352364
cache?: RequestCache;
353365
transferCache?: {includeHeaders?: string[]} | boolean;
366+
timeout?: number;
354367
},
355368
) {
356369
this.method = method.toUpperCase();
@@ -401,6 +414,17 @@ export class HttpRequest<T> {
401414
this.cache = options.cache;
402415
}
403416

417+
if (typeof options.timeout === 'number') {
418+
// XHR will ignore any value below 1. AbortSignals only accept unsigned integers.
419+
420+
if (options.timeout < 1 || !Number.isInteger(options.timeout)) {
421+
// TODO: create a runtime error
422+
throw new Error(ngDevMode ? '`timeout` must be a positive integer value' : '');
423+
}
424+
425+
this.timeout = options.timeout;
426+
}
427+
404428
// We do want to assign transferCache even if it's falsy (false is valid value)
405429
this.transferCache = options.transferCache;
406430
}
@@ -530,6 +554,7 @@ export class HttpRequest<T> {
530554
priority?: RequestPriority;
531555
cache?: RequestCache;
532556
transferCache?: {includeHeaders?: string[]} | boolean;
557+
timeout?: number;
533558
body?: T | null;
534559
method?: string;
535560
url?: string;
@@ -547,6 +572,7 @@ export class HttpRequest<T> {
547572
cache?: RequestCache;
548573
withCredentials?: boolean;
549574
transferCache?: {includeHeaders?: string[]} | boolean;
575+
timeout?: number;
550576
body?: V | null;
551577
method?: string;
552578
url?: string;
@@ -565,6 +591,7 @@ export class HttpRequest<T> {
565591
priority?: RequestPriority;
566592
cache?: RequestCache;
567593
transferCache?: {includeHeaders?: string[]} | boolean;
594+
timeout?: number;
568595
body?: any | null;
569596
method?: string;
570597
url?: string;
@@ -584,6 +611,8 @@ export class HttpRequest<T> {
584611
// `false` and `undefined` in the update args.
585612
const transferCache = update.transferCache ?? this.transferCache;
586613

614+
const timeout = update.timeout ?? this.timeout;
615+
587616
// The body is somewhat special - a `null` value in update.body means
588617
// whatever current body is present is being overridden with an empty
589618
// body, whereas an `undefined` value in update.body implies no
@@ -633,6 +662,7 @@ export class HttpRequest<T> {
633662
keepalive,
634663
cache,
635664
priority,
665+
timeout,
636666
});
637667
}
638668
}

packages/common/http/src/xhr.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,10 @@ export class HttpXhrBackend implements HttpBackend {
153153
}
154154
}
155155

156+
if (req.timeout) {
157+
xhr.timeout = req.timeout;
158+
}
159+
156160
// Set the responseType if one was requested.
157161
if (req.responseType) {
158162
const responseType = req.responseType.toLowerCase();
@@ -294,6 +298,21 @@ export class HttpXhrBackend implements HttpBackend {
294298
observer.error(res);
295299
};
296300

301+
let onTimeout = onError;
302+
303+
if (req.timeout) {
304+
onTimeout = (_: ProgressEvent) => {
305+
const {url} = partialFromXhr();
306+
const res = new HttpErrorResponse({
307+
error: new DOMException('Request timed out', 'TimeoutError'),
308+
status: xhr.status || 0,
309+
statusText: xhr.statusText || 'Request timeout',
310+
url: url || undefined,
311+
});
312+
observer.error(res);
313+
};
314+
}
315+
297316
// The sentHeaders flag tracks whether the HttpResponseHeaders event
298317
// has been sent on the stream. This is necessary to track if progress
299318
// is enabled since the event will be sent on only the first download
@@ -355,7 +374,7 @@ export class HttpXhrBackend implements HttpBackend {
355374
// By default, register for load and error events.
356375
xhr.addEventListener('load', onLoad);
357376
xhr.addEventListener('error', onError);
358-
xhr.addEventListener('timeout', onError);
377+
xhr.addEventListener('timeout', onTimeout);
359378
xhr.addEventListener('abort', onError);
360379

361380
// Progress events are only enabled if requested.
@@ -379,7 +398,7 @@ export class HttpXhrBackend implements HttpBackend {
379398
xhr.removeEventListener('error', onError);
380399
xhr.removeEventListener('abort', onError);
381400
xhr.removeEventListener('load', onLoad);
382-
xhr.removeEventListener('timeout', onError);
401+
xhr.removeEventListener('timeout', onTimeout);
383402

384403
if (req.reportProgress) {
385404
xhr.removeEventListener('progress', onDownProgress);

packages/common/http/test/fetch_spec.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ function trackEvents(obs: Observable<any>): Promise<any[]> {
3838

3939
const TEST_POST = new HttpRequest('POST', '/test', 'some body', {
4040
responseType: 'text',
41+
timeout: 1000,
4142
});
4243

4344
const TEST_POST_WITH_JSON_BODY = new HttpRequest(
@@ -284,7 +285,8 @@ describe('FetchBackend', async () => {
284285
backend.handle(TEST_POST).subscribe({
285286
error: (err: HttpErrorResponse) => {
286287
expect(err instanceof HttpErrorResponse).toBe(true);
287-
expect(err.error instanceof DOMException).toBeTruthy();
288+
expect(err.error instanceof DOMException).toBeTrue();
289+
expect((err.error as DOMException).name).toBe('AbortError');
288290
done();
289291
},
290292
});
@@ -329,10 +331,21 @@ describe('FetchBackend', async () => {
329331
cache: 'only-if-cached',
330332
}),
331333
);
332-
333334
fetchMock.mockFlush(HttpStatusCode.Ok, 'OK');
334335
});
335336

337+
it('emits an error when a request times out', (done) => {
338+
backend.handle(TEST_POST).subscribe({
339+
error: (err: HttpErrorResponse) => {
340+
expect(err instanceof HttpErrorResponse).toBe(true);
341+
expect(err.error instanceof DOMException).toBeTrue();
342+
expect((err.error as DOMException).name).toBe('TimeoutError');
343+
done();
344+
},
345+
});
346+
fetchMock.mockTimeoutEvent();
347+
});
348+
336349
describe('progress events', () => {
337350
it('are emitted for download progress', (done) => {
338351
backend
@@ -576,12 +589,13 @@ export class MockFetchFactory extends FetchFactory {
576589
}
577590

578591
mockAbortEvent() {
579-
// When `abort()` is called, the fetch() promise rejects with an Error of type DOMException,
580-
// with name AbortError. see
581-
// https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort
582592
this.reject(new DOMException('', 'AbortError'));
583593
}
584594

595+
mockTimeoutEvent() {
596+
this.reject(new DOMException('', 'TimeoutError'));
597+
}
598+
585599
resetFetchPromise() {
586600
this.promise = new Promise<Response>((resolve, reject) => {
587601
this.resolve = resolve;

0 commit comments

Comments
 (0)
0