Cancelling $http requests for fun and profit

17 Feb, 2015
Xebia Background Header Wave
At my current client, we have a large AngularJS application that is configured to show a full-page error whenever one of the $http requests ends up in error. This is implemented with an error interceptor as you would expect it to be. However, we’re also using some calculation-intense resources that happen to timeout once in a while. This combination is tricky: a user triggers a resource request when navigating to a certain page, navigates to a second page and suddenly ends up with an error message, as the request from the first page triggered a timeout error. This is a particular unpleasant side effect that I’m going to address in a generic way in this post. There are of course multiple solutions to this problem. We could create a more resilient implementation in the backend that will not time out, but accepts retries. We could change the full-page error in something less ‘in your face’ (but you still would get some out-of-place error notification). For this post I’m going to fix it using a different approach: cancel any running requests when a user switches to a different location (the route part of the URL). This makes sense; your browser does the same when navigating from one page to another, so why not mimic this behaviour in your Angular app? I’ve created a pretty verbose implementation to explain how to do this. At the end of this post, you’ll find a link to the code as a packaged bower component that can be dropped in any Angular 1.2+ app. To cancel a running request, Angular does not offer that many options. Under the hood, there are some places where you can hook into, but that won’t be necessary. If we look at the $http usage documentation, the timeout property is mentioned and it accepts a promise to abort the underlying call. Perfect! If we set a promise on all created requests, and abort these at once when the user navigates to another page, we’re (probably) all set. Let’s write an interceptor to plug in the promise in each request: [code language="javascript"] angular.module('angularCancelOnNavigateModule') .factory('HttpRequestTimeoutInterceptor', function ($q, HttpPendingRequestsService) { return { request: function (config) { config = config || {}; if (config.timeout === undefined && !config.noCancelOnRouteChange) { config.timeout = HttpPendingRequestsService.newTimeout(); } return config; } }; }); [/code] The interceptor will not overwrite the timeout property when it is explicitly set. Also, if the noCancelOnRouteChange option is set to true, the request won’t be cancelled. For better separation of concerns, I’ve created a new service (the HttpPendingRequestsService) that hands out new timeout promises and stores references to them. Let’s have a look at that pending requests service: [code language="javascript"] angular.module('angularCancelOnNavigateModule') .service('HttpPendingRequestsService', function ($q) { var cancelPromises = []; function newTimeout() { var cancelPromise = $q.defer(); cancelPromises.push(cancelPromise); return cancelPromise.promise; } function cancelAll() { angular.forEach(cancelPromises, function (cancelPromise) { cancelPromise.promise.isGloballyCancelled = true; cancelPromise.resolve(); }); cancelPromises.length = 0; } return { newTimeout: newTimeout, cancelAll: cancelAll }; }); [/code] So, this service creates new timeout promises that are stored in an array. When the cancelAll function is called, all timeout promises are resolved (thus aborting all requests that were configured with the promise) and the array is cleared. By setting the isGloballyCancelled property on the promise object, a response promise method can check whether it was cancelled or another exception has occurred. I’ll come back to that one in a minute. Now we hook up the interceptor and call the cancelAll function at a sensible moment. There are several events triggered on the root scope that are good hook candidates. Eventually I settled for $locationChangeSuccess. It is only fired when the location change is a success (hence the name) and not cancelled by any other event listener. [code language="javascript"] angular .module('angularCancelOnNavigateModule', []) .config(function($httpProvider) { $httpProvider.interceptors.push('HttpRequestTimeoutInterceptor'); }) .run(function ($rootScope, HttpPendingRequestsService) { $rootScope.$on('$locationChangeSuccess', function (event, newUrl, oldUrl) { if (newUrl !== oldUrl) { HttpPendingRequestsService.cancelAll(); } }) }); [/code] When writing tests for this setup, I found that the $locationChangeSuccess event is triggered at the start of each test, even though the location did not change yet. To circumvent this situation, the function does a simple difference check. Another problem popped up during testing. When the request is cancelled, Angular creates an empty error response, which in our case still triggers the full-page error. We need to catch and handle those error responses. We can simply add a responseError function in our existing interceptor. And remember the special isGloballyCancelled property we set on the promise? That’s the way to distinguish between cancelled and other responses. We add the following function to the interceptor: [code language="javascript"] responseError: function (response) { if (response.config.timeout.isGloballyCancelled) { return $q.defer().promise; } return $q.reject(response); } [/code] The responseError function must return a promise that normally re-throws the response as rejected. However, that’s not what we want: neither a success nor a failure callback should be called. We simply return a never-resolving promise for all cancelled requests to get the behaviour we want. That’s all there is to it! To make it easy to reuse this functionality in your Angular application, I’ve packaged this module as a bower component that is fully tested. You can check the module out on this GitHub repo.

Explore related posts