16

I want to globally intercept certain $http error scenarios, preventing controllers from handling the errors themselves. I think an HTTP interceptor is what I need, but I'm not sure how to get my controllers from also handling the error.

I have a controller like this:

function HomeController($location, $http) {
    activate();

    function activate() {
        $http.get('non-existent-location')
            .then(function activateOk(response) {
                alert('Everything is ok');
            })
            .catch(function activateError(error) {
                alert('An error happened');
            });
    }
}

And a HTTP interceptor like this:

function HttpInterceptor($q, $location) {
    var service = {
        responseError: responseError
    };

    return service;

    function responseError(rejection) {
        if (rejection.status === 404) {
            $location.path('/error');
        }
        return $q.reject(rejection);
    }
}

This works, in as much as the browser redirects to the '/error' path. But the promise catch in HomeController is also executing, and I don't want that.

I know I could code HomeController such that it ignores a 404 error, but that's not maintainable. Say I modify HttpInterceptor to also handle 500 errors, I'd then have to modify HomeController again (as well as any other controllers that might have since been added that use $http). Is there a more elegant solution?

Snixtor
  • 4,239
  • 2
  • 31
  • 54

5 Answers5

23

Option 1 - Break/cancel the promise chain

A small change in the HttpInterceptor can serve to break/cancel the promise chain, meaning that neither activateOk or activateError on the controller will be executed.

function HttpInterceptor($q, $location) {
    var service = {
        responseError: responseError
    };

    return service;

    function responseError(rejection) {
        if (rejection.status === 404) {
            $location.path('/error');
            return $q(function () { return null; })
        }
        return $q.reject(rejection);
    }
}

The line return $q(function () { return null; }), cancels the promise.

Whether this is "ok" is a topic of debate. Kyle Simpson in "You don't know JS" states:

Many Promise abstraction libraries provide facilities to cancel Promises, but this is a terrible idea! Many developers wish Promises had natively been designed with external cancelation capability, but the problem is that it would let one consumer/observer of a Promise affect some other consumer's ability to observe that same Promise. This violates the future-value's trustability (external immutability), but morever is the embodiment of the "action at a distance" anti-pattern...

Good? Bad? As I say, it's a topic of debate. I like the fact that it requires no change to any existing $http consumers.

Kyle's quite right when he says:

Many Promise abstraction libraries provide facilities to cancel Promises...

The Bluebird promise library for example has support for cancellation. From the documentation:

The new cancellation has "don't care" semantics while the old cancellation had abort semantics. Cancelling a promise simply means that its handler callbacks will not be called.

Option 2 - A different abstraction

Promises are a relatively broad abstraction. From the Promises/A+ specification:

A promise represents the eventual result of an asynchronous operation.

The Angular $http service uses the $q implementation of promises to return a promise for the eventual result of an asynchronous HTTP request.

It's worth nothing that $http has two deprecated functions, .success and .error, which decorate the returned promise. These functions were deprecated because they weren't chainable in the typical way promises are, and were deemed to not add much value as a "HTTP specific" set of functions.

But that's not to say we can't make our own HTTP abstraction / wrapper that doesn't even expose the underlying promise used by $http. Like this:

function HttpWrapper($http, $location) {
    var service = {
        get: function (getUrl, successCallback, errorCallback) {
            $http.get(getUrl).then(function (response) {
                successCallback(response);
            }, function (rejection) {
                if (rejection.status === 404) {
                    $location.path('/error');
                } else {
                    errorCallback(rejection);
                }
            });
        }
    };

    return service;
}

Being that this doesn't return a promise, its consumption needs to work a little differently too:

HttpWrapper.get('non-existent-location', getSuccess, getError);

function getSuccess(response) {
    alert('Everything is ok');
}

function getError(error) {
    alert('An error happened');
}

In the case of a 404, the location is changed to 'error', and neither getSuccess nor getError callbacks are executed.

This implementation means the ability to chain HTTP requests is no longer available. Is that an acceptable compromise? Results may vary...

Option 3 - Decorate the rejection

Credit to TJ for his comment:

if you need error handling in a particular controller, you will need conditions to check if an error has been handled in interceptor/service etc

The HTTP interceptor can decorate the promise rejection with a property handled to indicate whether it's handled the error.

function HttpInterceptor($q, $location) {
    var service = {
        responseError: responseError
    };

    return service;

    function responseError(rejection) {
        if (rejection.status === 404) {
            $location.path('/error');
            rejection.handled = true;
        }

        return $q.reject(rejection);
    }
}

Controller then looks like this:

$http.get('non-existent-location')
    .then(function activateOk(response) {
        alert('Everything is ok');
    })
    .catch(function activateError(error) {
        if (!error.handled) {
            alert('An error happened');
        }
    });

Summary

Unlike option 2, option 3 still leaves the option for any $http consumer to chain promises, which is a positive in the sense that it's not eliminating functionality.

Both options 2 and 3 have less "action at a distance". In the case of option 2, the alternative abstraction makes it clear that things will behave differently than the usual $q implementation. And for option 3, the consumer will still receive the promise to do with as it pleases.

All 3 options satisfy the maintainability criteria, as changes to the global error handler to handle more or less scenarios don't require changes to the consumers.

Snixtor
  • 4,239
  • 2
  • 31
  • 54
3

Wrap $http inside your own service. this way if you need to change your error handling logic, you don't need to change all your controllers.

Something like:

angular.module('test')
  .factory('http', ['$http',
      function(http) {
        return {
          get: function(getUrl) {
            return http.get(getUrl).then(function(response) {
              return response;
            }, function() {
              //handle errors here
            });
          },
          post: function(postUrl, data) {
              return http.post(postUrl, data).then(function(response) {
                return response;
              }, function() {
                //handle errors here
              });
            }
            // other $http wrappers
        };
      });
T J
  • 42,762
  • 13
  • 83
  • 138
  • In the case of my example, if I substitute `$http` for `htt`, the page shows an alert "Everything is ok", and redirects to the error page. The `response` parameter of the `activateOk` function is `undefined`. This is not a favourable outcome. – Snixtor May 25 '16 at 06:39
  • @Snixtor Your example is hitting an invalid url so it'll never invoke `activateOk`. It'll be `activateError` that gets called. My answer is not a copy paste solution. there is a comment `//handle errors here`, you need to add your logic like the one for ignoring `404` handled by interceptor . Once you have a service like this, you probably don't need an error handler in controller, if you ever need one, then you should return the error response. that's up to you o fill in. – T J May 25 '16 at 06:48
  • Sorry, I'm not sure what you mean by "invalid example". The outcome I describes is what happens if I put `$location.path('/error');` in place of "handle errors here". Unless there is a promise rejection or resolution in "handle errors here", the promise will resolve with `response` being undefined. – Snixtor May 25 '16 at 06:52
  • @Snixtor i mean invalid "url", that was a typo which I fixed later. This service is **not** a replacement for the interceptor. `$location.path('/error');` should be in interceptor unless you need to move it for some reason. The service is a common place to handle the logic you meant to add in the error handler in controller. *"Unless there is a promise rejection"* I believe you can do that, or simply return the error response in error handler as well. – T J May 25 '16 at 06:55
  • Thanks for clarifying, and I see what you mean now by "Once you have a service like this, you probably don't need an error handler in controller". But I **do** still want an error handler in the controller for every error other than a 404. My question isn't purely about functionality, it's about maintainability. As stated, I could get the controller to ignore 404 errors, but that would mean the controller is designed according to what the interceptor does. If I modified the interceptor to ignore 404 errors and instead handle 500, I'd need to modify the controller too. Not good. – Snixtor May 25 '16 at 07:03
  • @Snixtor By having a wrapper service that handles most of the common errors in a common way, you can avoid the controller having to handler errors. if you need error handling in a particular controller, you will need conditions to check if an error has been handled in interceptor/service etc. There is no way to get around that. – T J May 25 '16 at 07:10
1
application= angular.module('yourmodule', yourdependencies)  ;    
 application.config(["$provide", "$httpProvider", 
         function (provide, httpProvider){    
           registerInterceptors(provide, httpProvider);
        }]);

 registerhttpInterceptor = function (provide, httpProvider) {
                provide.factory("appHttpInterceptor", [
                    function () {
                        return {
                            responseError: function (rejection) {
                                if (rejection.status == 401) {
                                   return "Your custom response";
                                }
                            }
                        };
                    }
                ]);
                httpProvider.interceptors.push('appHttpInterceptor');
            };
sudil ravindran pk
  • 2,996
  • 3
  • 24
  • 29
  • I don't think you've fully grasped what I'm trying to achieve, returning "Your custom response" from `responseError` would mean that error handling is still the responsibility of the controller. – Snixtor May 25 '16 at 06:44
  • No, this code can exist globally, you are binding it to the module, you just need to inject "$provide" and "$httpProvider" and run when the module gets initialized – sudil ravindran pk May 25 '16 at 06:48
  • Local vs global presence is not the issue at hand. Your example will mean the `$http` promise in the controller is resolved with an object "Your custom response", which it would then be the responsibility of the *controller* to handle. I don't want the controller to be responsible for doing anything relating to the error. To look at it another way, neither `activateOk` nor `activateError` should be executed. – Snixtor May 25 '16 at 06:57
  • Instead of returning "your custom response" , you can call your activateok or activateError method . I am not sure whether you are looking for same, sorry if not – sudil ravindran pk May 25 '16 at 07:01
  • Also note , this method will get triggered before controller resolves $http promise – sudil ravindran pk May 25 '16 at 07:07
  • As stated in my question: "But the promise catch in HomeController is also executing, and I don't want that.". I'm looking for a *maintainable* way to handle *some* errors in an http interceptor (or similar) such that the controller does not need to be aware of the handling. – Snixtor May 25 '16 at 07:08
  • I think you can remove activateerror (catch method )logic from the controller, But i doubt whether it will help u in all scenarios. – sudil ravindran pk May 25 '16 at 07:14
1

This article explains how you can intercept http calls and perform different operations on it. One of them are handling errors.

A quick copy paste from the article above...

app.config(function($httpProvider) {
  $httpProvider.interceptors.push(function($q) {
    return {
      // This is the responseError interceptor
      responseError: function(rejection) {
        if (rejection.status > 399) { // assuming that any code over 399 is an error
          $q.reject(rejection)
        }

        return rejection;
      }
    };
  });
});
Stian Standahl
  • 2,506
  • 4
  • 33
  • 43
  • The use of `userService` in this case is an interesting study, it's basically deferring the rejection of the `$http` promise until it can be successfully resolved (the user logs in). But otherwise, it's just rejecting the promise and relying on the controller to handle the error. This doesn't address what I'm trying to accomplish. – Snixtor May 25 '16 at 06:48
  • I assumed that you took the essence from the article and the code. I can remove the code that is not relevant for handling the error – Stian Standahl May 25 '16 at 06:49
  • I do grasp the essence of the article, but it does not address the scenario I describe. The title of the article is actually quite fitting: "80/20 guide". I'd consider my scenario to be in the 20%, not the 80 % :) – Snixtor May 25 '16 at 07:13
  • I am a bit unsure in what you are after? do you want to stop execution of the promise chain down the line? I found this answer that is somewhat relevant http://stackoverflow.com/a/25976060/582061 – Stian Standahl May 25 '16 at 10:38
  • -1 as I'm not sure what this code is supposed to do. If you call `$q.reject`, it won't do anything because `$q.reject` _returns a new promise_ and this does nothing with the return value. Additionally, `responseError` would never be called for anything under `399` so the `if` statement should always run. Lastly `return rejection` is going to return the error response value _as a successfully resolved promise_ to the underlying code... which is going to be confusing for that code to receive the error object as a successful promise result. – Chris Foster Jan 25 '21 at 23:32
0

Try this one

factory/golbalErrorHandler.js

yourmodule.factory('golbalErrorHandler', [function() {  
        var golbalError= {
            responseError: function(response) {
                // Golbal Error Handling
                if (response.status != 200){
                    console.error(response);
                }
            }
        }
        return golbalError;

        }]);

app.js

   yourmodule.config(['$httpProvider', function($httpProvider) {  
       $httpProvider.interceptors.push('golbalErrorHandler');
    }]);
Ravi
  • 2,360
  • 1
  • 15
  • 11