可以将文章内容翻译成中文,广告屏蔽插件可能会导致该功能失效(如失效,请关闭广告屏蔽插件后再试):
问题:
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.
回答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.
回答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);
});
回答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!");
});
回答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();
}
回答5:
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.