41

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.

3
  • 1
    Having the same problem while implementing a Single Page Application that uses the 401's to trigger a login dialog. I've looked at the source and I think it's impossible to do with the Deferred's as they get resolved very quickly. Commented Sep 10, 2012 at 14:41
  • jsfiddle.net/8AgEj its working for me...whats the exact issue? Commented Sep 12, 2012 at 10:52
  • possible duplicate of What's the best way to retry an AJAX request on failure using jQuery? Commented Oct 10, 2014 at 13:56

5 Answers 5

55
+100

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.

Sign up to request clarification or add additional context in comments.

11 Comments

I like this solution. It keeps me from having to go in and replace every $.get and $.post call.
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.
Thanks @JulianAubourg - When the author of all this magic deferred/ajax suggests a change - you make it ;)
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
@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
|
16

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);
});

2 Comments

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!
This is amazing, great code! Thanks so much for this
9

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!");
});  

5 Comments

Nice, but I'd still have to specify how my AJAX calls should be handled at every call site.
+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 :)
Yeah, I like the plugin too. I think the ajaxPrefilter is a better approach in this specific use-case though.
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.
I know it has been a while, but this is a great plugin!
6

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();
}

5 Comments

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.
Why that random internal IIFE? You aren't scoping any variables/functions inside of it.
@gnarf: He's using it as a way to run the makeRequest both immediately, and then again inside the error handler.
Wouldn't this cause an infinite loop (and therefore a self DDOS attack) if the 401 status code kept being returned?
@MikeSherov Yes - if there's the possibility of that occurring, then there should be some kind of limit imposed
0

This is a great question that I just faced too.

I was daunted by the accepted answer (from @gnarf), so I figured out a way that I understood easier:

        var retryLimit = 3;
        var tryCount = 0;
        callAjax(payload);
        function callAjax(payload) {
            tryCount++;
            var newSaveRequest = $.ajax({
                url: '/survey/save',
                type: 'POST',
                data: payload,
                headers: {
                    'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
                },
                error: function (xhr, textStatus, errorThrown) {
                    if (textStatus !== 'abort') {
                        console.log('Error on ' + thisAnswerRequestNum, xhr, textStatus, errorThrown);
                        if (tryCount <= retryLimit) {
                            sleep(2000).then(function () {
                                if ($.inArray(thisAnswerRequestNum, abortedRequestIds) === -1) {
                                    console.log('Trying again ' + thisAnswerRequestNum);
                                    callAjax(payload);//try again
                                }
                            });
                            return;
                        }
                        return;
                    }
                }
            });
            newSaveRequest.then(function (data) {
                var newData = self.getDiffFromObjects(recentSurveyData, data);
                console.log("Answer was recorded " + thisAnswerRequestNum, newData);//, data, JSON.stringify(data)
                recentSurveyData = data;
            });
            self.previousQuizAnswerAjax = newSaveRequest;
            self.previousQuizAnswerIter = thisAnswerRequestNum;
        }


function sleep(milliseconds) {
    return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

Basically, I just wrapped the entire Ajax call and its callbacks into one function which can get called recursively.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.