16

I'm getting data from an async service inside my controller like this:

myApp.controller('myController', ['$scope', 'AsyncService',
function($scope, AsyncService) {
    $scope.getData = function(query) {
        return AsyncService.query(query).then(function(response) {
            // Got success response, return promise
            return response;
        }, function(reason) {
            // Got error, query again in one second
            // ???
        });
    }
}]);

My questions:

  1. How to query the service again when I get error from service without returning the promise.
  2. Would it be better to do this in my service?

Thanks!

Angelin
  • 648
  • 2
  • 7
  • 19

5 Answers5

22

You can retry the request in the service itself, not the controller.

So, AsyncService.query can be something like:

AsyncService.query = function() {
  var counter = 0
  var queryResults = $q.defer()

  function doQuery() {
    $http({method: 'GET', url: 'https://example.com'})
      .success(function(body) {
        queryResults.resolve(body)
      })
      .error(function() {
        if (counter < 3) {
          doQuery()
          counter++ 
        }
      })
  }

  return queryResults.promise
}

And you can get rid of your error function in the controller:

myApp.controller('myController', ['$scope', 'AsyncService',
  function($scope, AsyncService) {
    $scope.getData = function(query) {
      return AsyncService.query(query).then(function(response) {
        // Got success response
        return response;
      });
    }
  }
]);
M.K. Safi
  • 6,560
  • 10
  • 40
  • 58
  • 2
    May want to have a counter, if the server is down this will just loop over and over. – tymeJV Oct 25 '13 at 17:44
  • Or maybe it should be a backoff, with a x2 delay after every failure? – Dorian Jan 14 '14 at 20:07
  • Is there any way to make this into a universal http wrapper type scenario. To automatically do this kind of check on every http call? – Augie Gardner May 20 '14 at 17:35
  • @AugieGardner Perhaps by using application-level `$http` request interceptors in Angular. – M.K. Safi May 20 '14 at 21:11
  • When does doQuery() get called the first time? – Jesse Hattabaugh Jun 20 '14 at 18:33
  • @arkanciscan Yeah, it's an error with the code. You have to call `doQuery()` from within the `AsyncService` function. – M.K. Safi Jun 21 '14 at 10:05
  • 1
    Some notes on implementing exponential backoff (the 2x delay after every failure): https://developers.google.com/analytics/devguides/reporting/core/v3/coreErrors#backoff – Tom Dunn Mar 17 '15 at 15:02
  • This code will not work un Angular 1.4+. As the documentation says: "The $http legacy promise methods success and error have been deprecated. Use the standard then method instead...." – surfbuds Feb 25 '16 at 08:09
9

This actually works:

angular.module('retry_request', ['ng'])
  .factory('RetryRequest', ['$http', '$q', function($http, $q) {
    return function(path) {
      var MAX_REQUESTS = 3,
          counter = 1,
          results = $q.defer();

      var request = function() {
        $http({method: 'GET', url: path})
          .success(function(response) {
            results.resolve(response)
          })
          .error(function() {
            if (counter < MAX_REQUESTS) {
              request();
              counter++;
            } else {
              results.reject("Could not load after multiple tries");
            }
          });
      };

      request();

      return results.promise;
    }
  }]);

Then just an example of using it:

RetryRequest('/api/token').then(function(token) {
  // ... do something
});

You have to require it when declaring your module:

angular.module('App', ['retry_request']);

And in you controller:

app.controller('Controller', function($scope, RetryRequest) {
  ...
});

If someone wants to improve it with some kind of backoff or random timing to retry the request, that will be even better. I wish one day something like that will be in Angular Core

Dorian
  • 22,759
  • 8
  • 120
  • 116
2

I wrote an implementation with exponential backoff that doesn't use recursion (which would created nested stack frames, correct?) The way it's implemented has the cost of using multiple timers and it always creates all the stack frames for the make_single_xhr_call (even after success, instead of only after failure). I'm not sure if it's worth it (especially if the average case is a success) but it's food for thought.

I was worried about a race condition between calls but if javascript is single-threaded and has no context switches (which would allow one $http.success to be interrupted by another and allow it to execute twice), then we're good here, correct?

Also, I'm very new to angularjs and modern javascript so the conventions may be a little dirty also. Let me know what you think.

var app = angular.module("angular", []);

app.controller("Controller", ["$scope", "$http", "$timeout",
    function($scope, $http, $timeout) {

  /**
   * Tries to make XmlHttpRequest call a few times with exponential backoff.
   * 
   * The way this works is by setting a timeout for all the possible calls
   * to make_single_xhr_call instantly (because $http is asynchronous) and
   * make_single_xhr_call checks the global state ($scope.xhr_completed) to
   * make sure another request was not already successful.
   *
   * With sleeptime = 0, inc = 1000, the calls will be performed around:
   * t = 0
   * t = 1000 (+1 second)
   * t = 3000 (+2 seconds)
   * t = 7000 (+4 seconds)
   * t = 15000 (+8 seconds)
   */
  $scope.repeatedly_xhr_call_until_success = function() {
    var url = "/url/to/data";
    $scope.xhr_completed = false
    var sleeptime = 0;
    var inc = 1000;
    for (var i = 0, n = 5 ; i < n ; ++i) {
      $timeout(function() {$scope.make_single_xhr_call(url);}, sleeptime);
      sleeptime += inc;
      inc = (inc << 1); // multiply inc by 2
    }
  };

  /**
   * Try to make a single XmlHttpRequest and do something with the data.
   */
  $scope.make_single_xhr_call = function(url) {
    console.log("Making XHR Request to " + url);

    // avoid making the call if it has already been successful
    if ($scope.xhr_completed) return;
    $http.get(url)
      .success(function(data, status, headers) {
        // this would be later (after the server responded)-- maybe another
        // one of the calls has already completed.
        if ($scope.xhr_completed) return;
        $scope.xhr_completed = true;
        console.log("XHR was successful");
        // do something with XHR data
      })
      .error(function(data, status, headers) {
        console.log("XHR failed.");
      });
  };

}]);
mathewguest
  • 630
  • 5
  • 10
2

Following this article Promises in AngularJS, Explained as a Cartoon

you need to retry only when the response comes under 5XX category

I have written a service called http which can be called by passing all http configs as

 var params = {
  method: 'GET',
  url: URL,
  data: data
 }

then call the service method as follows:

   <yourDefinedAngularFactory>.http(params, function(err, response) {});

http: function(config, callback) {
  function request() {
    var counter = 0;
    var queryResults = $q.defer();

    function doQuery(config) {
      $http(config).success(function(response) {
        queryResults.resolve(response);
      }).error(function(response) {
        if (response && response.status >= 500 && counter < 3) {
          counter++;
          console.log('retrying .....' + counter);
          setTimeout(function() {
            doQuery(config);
          }, 3000 * counter);
        } else {
          queryResults.reject(response);
        }
      });
    }
    doQuery(config);
    return queryResults.promise;
  }
  request(config).then(function(response) {
    if (response) {
      callback(response.errors, response.data);
    } else {
      callback({}, {});
    }
  }, function(response) {
    if (response) {
      callback(response.errors, response.data);
    } else {
      callback({}, {});
    }
  });
}
Ratan Kumar
  • 1,640
  • 3
  • 25
  • 52
  • 2
    Good pick for the 5XX errors. It may be worthy to also retry when error.status equals -1 (timeout, no connection, ...). – VinceOPS Oct 31 '16 at 10:35
1

I ended up doing this a lot so I wrote a library to help address this problem : )

https://www.npmjs.com/package/reattempt-promise-function

In this example you could do something like

myApp.controller('myController', ['$scope', 'AsyncService',
function($scope, AsyncService) {
    var dogsQuery = { family: canine };
    $scope.online = true;
    $scope.getDogs = function() {
        return reattempt(AsyncService.query(dogsQuery)).then(function(dogs) {
            $scope.online = true;
            $scope.dogs = dogs;
        }).catch(function() {
            $scope.online = false;
        });
    }
}]);