Recently we need to make a new feature that allows users to cancel an ongoing HTTP request. This is a fairly reasonable requirement and technically not difficult to implement, however, our architecture, which consists of AngularJS and ngRedux, makes it more challenging.

In this post I am going to propose an approach for cancelling HTTP requests.

The Problem in Promise Chain

AngularJS provides a easy way to cancel an HTTP request. $http accepts a timeout option which can be a promise. The HTTP request will be cancelled when the timeout promise is resolved. Here is an example:

function fetchBooks() {

const canceller = $q.defer();

return $http.get('/api/books/', { timeout: canceller.promise });

}

In above snippet, we can easily cancel the HTTP request by calling canceller.promise.resolve() . However the problem is obvious: canceller is not returned in fetchBooks() . In another word, the caller of fetchBooks() won’t have the control of cancelling the request.

Of course we can assign canceller to the result promise, like this:

function fetchBooks() {

const canceller = $q.defer();

let promise = $http.get('/api/books/', {

timeout: canceller.promise

});

promise.cancel = () => {

canceller.resolve();

}

return promise;

} // Caller

const books = fetchBooks(); // Later..

books.cancel();

This looks good, however there is only one problem. If the promise returned by fetchBooks() is further used in a promise chain, this approach will fail. For example, the following code is used in redux actions very commonly:

const action = () => dispatch => {

return fetchBooks()

.then(response => response.results)

.then(books => {

dispatch(updateBooks(books));

return books;

}); // Caller

const books = action();

books.cancel(); // ERROR: books.cancel === undefined

The .then() will generate a new promise, thus the books returned by action() is no longer the original promise returned by $http.get() .

See the problem? In short, we don’t have access to the cancel() method because:

The only way cancel() can be exposed is being attached to the result promise;

can be exposed is being attached to the result promise; Result promise is lost when passing through promise chain.

Under the Hood

If we take a closer look at the implementation of the .then() method in AngularJS, we will find something like this:

extend(Promise.prototype, {

then: function(onFulfilled, onRejected, progressBack) {

if (isUndefined(onFulfilled) && isUndefined(onRejected) && isUndefined(progressBack)) {

return this;

}

var result = new Deferred();



this.$$state.pending = this.$$state.pending || [];

this.$$state.pending.push([result, onFulfilled, onRejected, progressBack]);

if (this.$$state.status > 0) scheduleProcessQueue(this.$$state);



return result.promise;

},

...

We can see that the old promise has a reference ( this.$$state.pending[0][0] ) to the new promise. To make this clear, consider the following setup:

let promiseA = $http.get('/');

promiseB = promiseA.then(response => response);

promiseC = promiseB.then(repsonse => response);

Then:

promiseA.$$state.pending[0][0].promise === promiseB

promiseB.$$state.pending[0][0].promise === promiseC

The key here is that the reference is from the oldest to the latest. We cannot traverse from the latest promise promiseC back to the root promise promiseA . However we can create a list for the ongoing HTTP requests, and provide a method cancelPromise(promise) that accepts any promise in the promise chain. When cancelPromise(promise) is called, we will traverse the HTTP request list and find the corresponding promise chain, then invoke the .cancel() method of the root promise.

Implementation

We defined a service to hold the HTTP requests:

class PromiseService { constructor() {

this._requests = [];

} register(promise) {

this._requests.push(promise);

} unregister(promise) {

const idx = this._requests.indexOf(promise);

if (idx >= 0) {

this._requests.splice(idx, 1);

}

} cancelPromise(promise) {

for (let i = 0; i < this._requests.length; i++) {

const rootPromise = this._requests[i];

let p = rootPromise; // Traverse the promise chain to see

// if given promise exists in the chain

while (p !== promise && p.$$state.pending &&

p.$$state.pending.length > 0) {

p = p.$$state.pending[0][0].promise;

} // If this chain contains given promise, then call the

// cancel method to cancel the http request

if (p === promise && typeof r.cancel === 'function') {

r.cancel();

}

}

}

}

And in the API call:

function fetchBooks() {

const canceller = $q.defer();

let promise = $http.get('/api/books/', {

timeout: canceller.promise,

}); promise.cancel = () => {

canceller.resolve();

} promiseService.register(promise); // If HTTP requests complete before cancel,

// then remove the entry from PromiseService

return promise.then((response) => {

promiseService.unregister(promise);

return response;

});

}

With this being done, we can simply invoke promiseService.cancel() :