Skip to content

Commit

Permalink
feat(http): Add timeout option to HTTP requests
Browse files Browse the repository at this point in the history
Add timeout option to both XHR and fetch backends.

POTENTIAL BREAKING CHANGE: Aborted requests with fetch backend no longer throw DOMException
  • Loading branch information
wartab committed Jul 30, 2024
1 parent dd56270 commit 77765c5
Show file tree
Hide file tree
Showing 8 changed files with 147 additions and 29 deletions.
22 changes: 20 additions & 2 deletions adev/src/content/guide/http/making-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,19 +198,37 @@ Each `HttpEvent` reported in the event stream has a `type` which distinguishes w

## Handling request failure

There are two ways an HTTP request can fail:
There are three ways an HTTP request can fail:

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

`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.
`HttpClient` captures both 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.

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

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.

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.

### Timeouts

In order to set a timeout for a request, you can set the `timeout` option to a number of milliseconds along other request options. If the request does not complete within the specified time, the request will be aborted and an error will be emitted.

<docs-code language="ts">
http.get('/api/config', {
timeout: 3000,
}).subscribe({
next: config => {
console.log('Config fetched successfully:', config);
},
error: err => {
// If the request times out, an error will have been emitted.
}
});
</docs-code>

## Http `Observable`s

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`.
Expand Down
25 changes: 24 additions & 1 deletion packages/common/http/src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ const XSSI_PREFIX = /^\)\]\}',?\n/;

const REQUEST_URL_HEADER = `X-Request-URL`;

const USER_ABORT = 0;
const TIMEOUT_ABORT = 1;

/**
* Determine an appropriate URL for the response, by checking either
* response url or the X-Request-URL header.
Expand Down Expand Up @@ -60,10 +63,22 @@ export class FetchBackend implements HttpBackend {
handle(request: HttpRequest<any>): Observable<HttpEvent<any>> {
return new Observable((observer) => {
const aborter = new AbortController();

this.doRequest(request, aborter.signal, observer).then(noop, (error) =>
observer.error(new HttpErrorResponse({error})),
);
return () => aborter.abort();

if (request.timeout) {
this.ngZone.runOutsideAngular(() => {
setTimeout(() => {
if (!aborter.signal.aborted) {
aborter.abort(TIMEOUT_ABORT);
}
}, request.timeout);
});
}

return () => aborter.abort(USER_ABORT);
});
}

Expand Down Expand Up @@ -93,6 +108,14 @@ export class FetchBackend implements HttpBackend {

response = await fetchPromise;
} catch (error: any) {
if (signal.aborted && error === signal.reason) {
if (signal.reason === TIMEOUT_ABORT) {
error = new Error('Request timed out');
} else if (signal.reason === USER_ABORT) {
error = new Error('Request aborted');
}
}

observer.error(
new HttpErrorResponse({
error,
Expand Down
24 changes: 24 additions & 0 deletions packages/common/http/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface HttpRequestInit {
responseType?: 'arraybuffer' | 'blob' | 'json' | 'text';
withCredentials?: boolean;
transferCache?: {includeHeaders?: string[]} | boolean;
timeout?: number;
}

/**
Expand Down Expand Up @@ -159,6 +160,11 @@ export class HttpRequest<T> {
*/
readonly transferCache?: {includeHeaders?: string[]} | boolean;

/**
* The timeout for the request in ms.
*/
readonly timeout?: number;

constructor(
method: 'GET' | 'HEAD',
url: string,
Expand All @@ -178,6 +184,7 @@ export class HttpRequest<T> {
* particular request
*/
transferCache?: {includeHeaders?: string[]} | boolean;
timeout?: number;
},
);
constructor(
Expand All @@ -190,6 +197,7 @@ export class HttpRequest<T> {
params?: HttpParams;
responseType?: 'arraybuffer' | 'blob' | 'json' | 'text';
withCredentials?: boolean;
timeout?: number;
},
);
constructor(
Expand All @@ -212,6 +220,7 @@ export class HttpRequest<T> {
* particular request
*/
transferCache?: {includeHeaders?: string[]} | boolean;
timeout?: number;
},
);
constructor(
Expand All @@ -225,6 +234,7 @@ export class HttpRequest<T> {
params?: HttpParams;
responseType?: 'arraybuffer' | 'blob' | 'json' | 'text';
withCredentials?: boolean;
timeout?: number;
},
);
constructor(
Expand All @@ -247,6 +257,7 @@ export class HttpRequest<T> {
* particular request
*/
transferCache?: {includeHeaders?: string[]} | boolean;
timeout?: number;
},
);
constructor(
Expand All @@ -262,6 +273,7 @@ export class HttpRequest<T> {
responseType?: 'arraybuffer' | 'blob' | 'json' | 'text';
withCredentials?: boolean;
transferCache?: {includeHeaders?: string[]} | boolean;
timeout?: number;
}
| null,
fourth?: {
Expand All @@ -272,6 +284,7 @@ export class HttpRequest<T> {
responseType?: 'arraybuffer' | 'blob' | 'json' | 'text';
withCredentials?: boolean;
transferCache?: {includeHeaders?: string[]} | boolean;
timeout?: number;
},
) {
this.method = method.toUpperCase();
Expand Down Expand Up @@ -314,6 +327,11 @@ export class HttpRequest<T> {
this.params = options.params;
}

// A timeout of 0ms will get discarded.
if (!!options.timeout) {
this.timeout = options.timeout;
}

// We do want to assign transferCache even if it's falsy (false is valid value)
this.transferCache = options.transferCache;
}
Expand Down Expand Up @@ -440,6 +458,7 @@ export class HttpRequest<T> {
responseType?: 'arraybuffer' | 'blob' | 'json' | 'text';
withCredentials?: boolean;
transferCache?: {includeHeaders?: string[]} | boolean;
timeout?: number;
body?: T | null;
method?: string;
url?: string;
Expand All @@ -454,6 +473,7 @@ export class HttpRequest<T> {
responseType?: 'arraybuffer' | 'blob' | 'json' | 'text';
withCredentials?: boolean;
transferCache?: {includeHeaders?: string[]} | boolean;
timeout?: number;
body?: V | null;
method?: string;
url?: string;
Expand All @@ -469,6 +489,7 @@ export class HttpRequest<T> {
responseType?: 'arraybuffer' | 'blob' | 'json' | 'text';
withCredentials?: boolean;
transferCache?: {includeHeaders?: string[]} | boolean;
timeout?: number;
body?: any | null;
method?: string;
url?: string;
Expand All @@ -486,6 +507,8 @@ export class HttpRequest<T> {
// `false` and `undefined` in the update args.
const transferCache = update.transferCache ?? this.transferCache;

const timeout = update.timeout ?? this.timeout;

// The body is somewhat special - a `null` value in update.body means
// whatever current body is present is being overridden with an empty
// body, whereas an `undefined` value in update.body implies no
Expand Down Expand Up @@ -532,6 +555,7 @@ export class HttpRequest<T> {
responseType,
withCredentials,
transferCache,
timeout,
});
}
}
23 changes: 21 additions & 2 deletions packages/common/http/src/xhr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ export class HttpXhrBackend implements HttpBackend {
}
}

if (req.timeout) {
xhr.timeout = req.timeout;
}

// Set the responseType if one was requested.
if (req.responseType) {
const responseType = req.responseType.toLowerCase();
Expand Down Expand Up @@ -249,6 +253,21 @@ export class HttpXhrBackend implements HttpBackend {
observer.error(res);
};

let onTimeout = onError;

if (req.timeout) {
onTimeout = (error: ProgressEvent) => {
const {url} = partialFromXhr();
const res = new HttpErrorResponse({
error,
status: xhr.status || 0,
statusText: xhr.statusText || 'Request timeout',
url: url || undefined,
});
observer.error(res);
};
}

// The sentHeaders flag tracks whether the HttpResponseHeaders event
// has been sent on the stream. This is necessary to track if progress
// is enabled since the event will be sent on only the first download
Expand Down Expand Up @@ -310,7 +329,7 @@ export class HttpXhrBackend implements HttpBackend {
// By default, register for load and error events.
xhr.addEventListener('load', onLoad);
xhr.addEventListener('error', onError);
xhr.addEventListener('timeout', onError);
xhr.addEventListener('timeout', onTimeout);
xhr.addEventListener('abort', onError);

// Progress events are only enabled if requested.
Expand All @@ -334,7 +353,7 @@ export class HttpXhrBackend implements HttpBackend {
xhr.removeEventListener('error', onError);
xhr.removeEventListener('abort', onError);
xhr.removeEventListener('load', onLoad);
xhr.removeEventListener('timeout', onError);
xhr.removeEventListener('timeout', onTimeout);

if (req.reportProgress) {
xhr.removeEventListener('progress', onDownProgress);
Expand Down
25 changes: 20 additions & 5 deletions packages/common/http/test/fetch_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function trackEvents(obs: Observable<any>): Promise<any[]> {

const TEST_POST = new HttpRequest('POST', '/test', 'some body', {
responseType: 'text',
timeout: 1000,
});

const TEST_POST_WITH_JSON_BODY = new HttpRequest(
Expand Down Expand Up @@ -281,13 +282,26 @@ describe('FetchBackend', async () => {
backend.handle(TEST_POST).subscribe({
error: (err: HttpErrorResponse) => {
expect(err instanceof HttpErrorResponse).toBe(true);
expect(err.error instanceof DOMException).toBeTruthy();
expect(err.error instanceof Error).toBeTrue();
expect(err.error.message).toBe('Request aborted');
done();
},
});
fetchMock.mockAbortEvent();
});

it('emits an error when a request times out', (done) => {
backend.handle(TEST_POST).subscribe({
error: (err: HttpErrorResponse) => {
expect(err instanceof HttpErrorResponse).toBe(true);
expect(err.error instanceof Error).toBeTrue();
expect(err.error.message).toBe('Request timed out');
done();
},
});
fetchMock.mockTimeoutEvent();
});

describe('progress events', () => {
it('are emitted for download progress', (done) => {
backend
Expand Down Expand Up @@ -495,10 +509,11 @@ export class MockFetchFactory extends FetchFactory {
}

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

mockTimeoutEvent() {
this.reject(new Error('Request timed out'));
}

resetFetchPromise() {
Expand Down
5 changes: 5 additions & 0 deletions packages/common/http/test/request_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ describe('HttpRequest', () => {
responseType: 'text',
withCredentials: true,
transferCache: true,
timeout: 1000,
});
it('in the base case', () => {
const clone = req.clone();
Expand All @@ -89,6 +90,7 @@ describe('HttpRequest', () => {

expect(clone.context).toBe(context);
expect(clone.transferCache).toBe(true);
expect(clone.timeout).toBe(1000);
});
it('and updates the url', () => {
expect(req.clone({url: '/changed'}).url).toBe('/changed');
Expand All @@ -106,6 +108,9 @@ describe('HttpRequest', () => {
it('and updates the transferCache', () => {
expect(req.clone({transferCache: false}).transferCache).toBe(false);
});
it('and updates the timeout', () => {
expect(req.clone({timeout: 5000}).timeout).toBe(5000);
});
});
describe('content type detection', () => {
const baseReq = new HttpRequest('POST', '/test', null);
Expand Down
1 change: 1 addition & 0 deletions packages/common/http/test/xhr_mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export class MockXMLHttpRequest {
// Directly settable interface.
withCredentials: boolean = false;
responseType: string = 'text';
timeout: number | undefined = undefined;

// Mocked response interface.
response: any | undefined = undefined;
Expand Down
Loading

0 comments on commit 77765c5

Please sign in to comment.