Retry a jquery ajax request which has callbacks attached to its deferred

18,361

Solution 1

You could use jQuery.ajaxPrefilter to wrap the jqXHR in another deferred object.

I made an example on jsFiddle that shows it working, and tried to adapt some of your code to handle the 401 into this version:

$.ajaxPrefilter(function(opts, originalOpts, jqXHR) {
    // you could pass this option in on a "retry" so that it doesn't
    // get all recursive on you.
    if (opts.refreshRequest) {
        return;
    }

    // our own deferred object to handle done/fail callbacks
    var dfd = $.Deferred();

    // if the request works, return normally
    jqXHR.done(dfd.resolve);

    // if the request fails, do something else
    // yet still resolve
    jqXHR.fail(function() {
        var args = Array.prototype.slice.call(arguments);
        if (jqXHR.status === 401) {
            $.ajax({
                url: '/refresh',
                refreshRequest: true,
                error: function() {
                    // session can't be saved
                    alert('Your session has expired. Sorry.');
                    // reject with the original 401 data
                    dfd.rejectWith(jqXHR, args);
                },
                success: function() {
                    // retry with a copied originalOpts with refreshRequest.
                    var newOpts = $.extend({}, originalOpts, {
                        refreshRequest: true
                    });
                    // pass this one on to our deferred pass or fail.
                    $.ajax(newOpts).then(dfd.resolve, dfd.reject);
                }
            });

        } else {
            dfd.rejectWith(jqXHR, args);
        }
    });

    // NOW override the jqXHR's promise functions with our deferred
    return dfd.promise(jqXHR);
});

This works because deferred.promise(object) will actually overwrite all of the "promise methods" on the jqXHR.

NOTE: To anyone else finding this, if you are attaching callbacks with success: and error: in the ajax options, this snippet will not work the way you expect. It assumes that the only callbacks are the ones attached using the .done(callback) and .fail(callback) methods of the jqXHR.

Solution 2

As gnarf's answer notes, success and error callbacks will not behave as expected. If anyone is interested here is a version that supports both success and error callbacks as well as promises style events.

$.ajaxPrefilter(function (options, originalOptions, jqXHR) {

    // Don't infinitely recurse
    originalOptions._retry = isNaN(originalOptions._retry)
        ? Common.auth.maxExpiredAuthorizationRetries
        : originalOptions._retry - 1;

    // set up to date authorization header with every request
    jqXHR.setRequestHeader("Authorization", Common.auth.getAuthorizationHeader());

    // save the original error callback for later
    if (originalOptions.error)
        originalOptions._error = originalOptions.error;

    // overwrite *current request* error callback
    options.error = $.noop();

    // setup our own deferred object to also support promises that are only invoked
    // once all of the retry attempts have been exhausted
    var dfd = $.Deferred();
    jqXHR.done(dfd.resolve);

    // if the request fails, do something else yet still resolve
    jqXHR.fail(function () {
        var args = Array.prototype.slice.call(arguments);

        if (jqXHR.status === 401 && originalOptions._retry > 0) {

            // refresh the oauth credentials for the next attempt(s)
            // (will be stored and returned by Common.auth.getAuthorizationHeader())
            Common.auth.handleUnauthorized();

            // retry with our modified
            $.ajax(originalOptions).then(dfd.resolve, dfd.reject);

        } else {
            // add our _error callback to our promise object
            if (originalOptions._error)
                dfd.fail(originalOptions._error);
            dfd.rejectWith(jqXHR, args);
        }
    });

    // NOW override the jqXHR's promise functions with our deferred
    return dfd.promise(jqXHR);
});

Solution 3

I have created a jQuery plugin for this use case. It wraps the logic described in gnarf's answer in a plugin and additionally allows you to specify a timeout to wait before attempting the ajax call again. For example.

//this will try the ajax call three times in total 
//if there is no error, the success callbacks will be fired immediately
//if there is an error after three attempts, the error callback will be called

$.ajax(options).retry({times:3}).then(function(){
  alert("success!");
}); 

//this has the same sematics as above, except will 
//wait 3 seconds between attempts
$.ajax(options).retry({times:3, timeout:3000}).retry(3).then(function(){
   alert("success!");
});  

Solution 4

Would something like this work out for you? You just need to return your own Deferred/Promise so that the original one isn't rejected too soon.

Example/test usage: http://jsfiddle.net/4LT2a/3/

function doSomething() {
    var dfr = $.Deferred();

    (function makeRequest() {
        $.ajax({
            url: "someurl",
            dataType: "json",
            success: dfr.resolve,
            error: function( jqXHR ) {
                if ( jqXHR.status === 401 ) {
                    return makeRequest( this );
                }

                dfr.rejectWith.apply( this, arguments );
            }
        });
    }());

    return dfr.promise();
}
Share:
18,361
cipak
Author by

cipak

Updated on June 24, 2022

Comments

  • cipak
    cipak almost 2 years

    I'm trying to implement a system of retrying ajax requests that fail for a temporary reason. In my case, it is about retrying requests that failed with a 401 status code because the session has expired, after calling a refresh webservice that revives the session.

    The problem is that the "done" callbacks are not called on a successful retry, unlike the "success" ajax option callback that is called. I've made up a simple example below:

    $.ajaxSetup({statusCode: {
        404: function() {
            this.url = '/existent_url';
            $.ajax(this);
        }
    }});
    
    $.ajax({
        url: '/inexistent_url',
        success: function() { alert('success'); }
    })
    .done(function() {
        alert('done');
    });
    

    Is there a way to have done-style callbacks called on a successful retry? I know a deferred can't be 'resolved' after it was 'rejected', is it possible to prevent the reject? Or maybe copy the doneList of the original deferred to a new deferred? I'm out of ideas:)

    A more realistic example below, where I'm trying to queue up all 401-rejected requests, and retry them after a successful call to /refresh.

    var refreshRequest = null,
        waitingRequests = null;
    
    var expiredTokenHandler = function(xhr, textStatus, errorThrown) {
    
        //only the first rejected request will fire up the /refresh call
        if(!refreshRequest) {
            waitingRequests = $.Deferred();
            refreshRequest = $.ajax({
                url: '/refresh',
                success: function(data) {
                    // session refreshed, good
                    refreshRequest = null;
                    waitingRequests.resolve();
                },
                error: function(data) {
                    // session can't be saved
                    waitingRequests.reject();
                    alert('Your session has expired. Sorry.');
                }
           });
        }
    
        // put the current request into the waiting queue
        (function(request) {
            waitingRequests.done(function() {
                // retry the request
                $.ajax(request);
            });
        })(this);
    }
    
    $.ajaxSetup({statusCode: {
        401: expiredTokenHandler
    }});
    

    The mechanism works, the 401-failed requests get fired a second time, the problem is their 'done' callbacks do not get called, so the applications stalls.

  • Dave Van den Eynde
    Dave Van den Eynde over 11 years
    That's how I solved it last night, albeit a bit more elaborate than this. If @cipak accepts this as the right answer, I'll award the bounty.
  • gnarf
    gnarf over 11 years
    Why that random internal IIFE? You aren't scoping any variables/functions inside of it.
  • Matt
    Matt over 11 years
    @gnarf: He's using it as a way to run the makeRequest both immediately, and then again inside the error handler.
  • Mike Sherov
    Mike Sherov over 11 years
    Wouldn't this cause an infinite loop (and therefore a self DDOS attack) if the 401 status code kept being returned?
  • Dave Van den Eynde
    Dave Van den Eynde over 11 years
    I like this solution. It keeps me from having to go in and replace every $.get and $.post call.
  • Dave Van den Eynde
    Dave Van den Eynde over 11 years
    Nice, but I'd still have to specify how my AJAX calls should be handled at every call site.
  • Julian Aubourg
    Julian Aubourg over 11 years
    you'd probably better use something like $.extend( {}, originalOpts, { refreshRequest: true } ) rather than opts for the retry given how processed this object has already been by ajax.
  • gnarf
    gnarf over 11 years
    Thanks @JulianAubourg - When the author of all this magic deferred/ajax suggests a change - you make it ;)
  • gnarf
    gnarf over 11 years
    +1 - If the goal is a simple retry, this plugin is solid. I really like the API of extending the ajax requests with .retry(times) - Also I just submitted a few pull requests to clean it up / make it more efficient :)
  • dherman
    dherman over 11 years
    @MikeSherov Yes - if there's the possibility of that occurring, then there should be some kind of limit imposed
  • dherman
    dherman over 11 years
    I really like that usage of .promise - didn't know about that. FWIW in the fail handler, you may want to use jqXHR rather than this, otherwise this would break if the context option was used
  • gnarf
    gnarf over 11 years
    @dherman - Thanks, incorporated that suggestion, great point.
  • Julian Aubourg
    Julian Aubourg over 11 years
    Yeah, I like the plugin too. I think the ajaxPrefilter is a better approach in this specific use-case though.
  • Ciantic
    Ciantic over 11 years
    I'd like to have a ability to retry on demand, for example when I get in done callback a data object with error property having some value. In this case it would be handy if we could just add jqxhr object a method retry, which handlers could call. But how can I populate handlers from the old jqxhr?
  • Ciantic
    Ciantic over 11 years
    Is it possible to retry on done callback? E.g. if done callback gets data property error with value "LOGIN_REQUIRED" I'd like to retry it on demand. Logic for retrying or not must be inside done or fail callback... Can't wrap my head around it yet.
  • gnarf
    gnarf over 11 years
    @ciantic sounds like it's own question :)
  • dherman
    dherman over 11 years
    @Ciantic If you're still looking for something, I've expanded on this solution to attempt full API support including promises and custom retry logic - plugins.jquery.com/jquery.ajaxRetry/0.1.2
  • JoeBrockhaus
    JoeBrockhaus about 9 years
    @dherman, does your plugin block the jquery deferred from resolving while a retry happens? For instance, say I make an initial request, after which I need to poll to check for a particular status. If the particular status is received within n tries, resolve the jXHR, otherwise reject it. It looks like currently the ajax call will resolve independently of the retry behavior.
  • dherman
    dherman about 9 years
    @JoeBrockhaus the original promise will not be resolved until the internal ajax request either succeeds or the request should no longer be retried, in which case it's rejected. If your initial request is successful, then the promise will be resolved. This plugin isn't really intended for a polling implementation since any successful response will resolve the promise. You can easily do what you want by just using a recursive function that returns promises.
  • jon_wu
    jon_wu almost 9 years
    @gnarf is Array.prototype.slice(arguments) necessary? MDN mentions that it's a bad practice as it prevents V8 from optimizing: developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/…. It seems like using arguments in directly works but I wasn't sure if this was to work around something.
  • mpen
    mpen about 8 years
    I've only briefly tested this, but so far it's working flawlessly. This is fantastic. Now when a user's session expires before they submit a form they get a prompt to sign in again, and then their form is submitted as soon as they successfully log in. No more lost data!
  • Michael Rashkovsky
    Michael Rashkovsky about 8 years
    This is amazing, great code! Thanks so much for this
  • Jason Washo
    Jason Washo almost 7 years
    I know it has been a while, but this is a great plugin!