Skip to content

Commit 055cb5b

Browse files
Fix JWT retry logic (#461)
1 parent 4953041 commit 055cb5b

File tree

6 files changed

+308
-58
lines changed

6 files changed

+308
-58
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Upcoming Release
44

55
- Added a getter method (getNextMarker()) for the next marker to PagingIterator
6+
- Fixed JWT retry logic so the a new JTI claim is generated on each retry
67

78
## 1.30.0 [2019-11-21]
89

lib/api-request.js

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ var assert = require('assert'),
1515
request = require('request'),
1616
EventEmitter = require('events').EventEmitter,
1717
Config = require('./util/config'),
18-
httpStatusCodes = require('http-status');
18+
httpStatusCodes = require('http-status'),
19+
getRetryTimeout = require('./util/exponential-backoff');
1920

2021
// ------------------------------------------------------------------------------
2122
// Typedefs and Callbacks
@@ -94,9 +95,6 @@ var retryableStatusCodes = {};
9495
retryableStatusCodes[httpStatusCodes.REQUEST_TIMEOUT] = true;
9596
retryableStatusCodes[httpStatusCodes.TOO_MANY_REQUESTS] = true;
9697

97-
// Retry intervals are between 50% and 150% of the exponentially increasing base amount
98-
const RETRY_RANDOMIZATION_FACTOR = 0.5;
99-
10098
/**
10199
* Returns true if the response info indicates a temporary/transient error.
102100
*
@@ -157,21 +155,6 @@ function cleanSensitiveHeaders(requestObj) {
157155
}
158156
}
159157

160-
/**
161-
* Calculate the exponential backoff time with randomized jitter
162-
* @param {int} numRetries Which retry number this one will be
163-
* @param {int} baseInterval The base retry interval set in config
164-
* @returns {int} The number of milliseconds after which to retry
165-
*/
166-
function getRetryTimeout(numRetries, baseInterval) {
167-
168-
var minRandomization = 1 - RETRY_RANDOMIZATION_FACTOR;
169-
var maxRandomization = 1 + RETRY_RANDOMIZATION_FACTOR;
170-
var randomization = (Math.random() * (maxRandomization - minRandomization)) + minRandomization;
171-
var exponential = Math.pow(2, numRetries - 1);
172-
return Math.ceil(exponential * baseInterval * randomization);
173-
}
174-
175158
// ------------------------------------------------------------------------------
176159
// Public
177160
// ------------------------------------------------------------------------------
@@ -264,8 +247,12 @@ APIRequest.prototype._handleResponse = function(err, response) {
264247
// Have the SDK emit the error response
265248
this.eventBus.emit('response', err);
266249

267-
// If our APIRequest instance is retryable, attempt a retry. Otherwise, finish and propagate the error.
268-
if (this.isRetryable) {
250+
var isJWT = false;
251+
if (this.config.request.hasOwnProperty('form') && this.config.request.form.hasOwnProperty('grant_type') && this.config.request.form.grant_type === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
252+
isJWT = true;
253+
}
254+
// If our APIRequest instance is retryable, attempt a retry. Otherwise, finish and propagate the error. Doesn't retry when the request is for JWT authentication, since that is handled in retryJWTGrant.
255+
if (this.isRetryable && !isJWT) {
269256
this._retry(err);
270257
} else {
271258
this._finish(err);
@@ -318,6 +305,8 @@ APIRequest.prototype._retry = function(err) {
318305
this._finish(err);
319306
return;
320307
}
308+
} else if (err.response.hasOwnProperty('headers') && err.response.headers.hasOwnProperty('retry-after')) {
309+
retryTimeout = err.response.headers['retry-after'] * 1000;
321310
} else {
322311
retryTimeout = getRetryTimeout(this.numRetries, this.config.retryIntervalMS);
323312
}

lib/token-manager.js

Lines changed: 84 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,12 @@
5353
*/
5454
function isJWTAuthErrorRetryable(err) {
5555

56-
return err.authExpired
57-
&& err.response.headers.date
58-
&& (
59-
err.response.body.error_description.indexOf('exp') > -1
60-
|| err.response.body.error_description.indexOf('jti') > -1
61-
);
56+
if (err.authExpired && err.response.headers.date && (err.response.body.error_description.indexOf('exp') > -1 || err.response.body.error_description.indexOf('jti') > -1)) {
57+
return true;
58+
} else if (err.statusCode === 429 || err.statusCode >= 500) {
59+
return true;
60+
}
61+
return false;
6262
}
6363

6464
// ------------------------------------------------------------------------------
@@ -68,7 +68,8 @@ var errors = require('./util/errors'),
6868
jwt = require('jsonwebtoken'),
6969
uuid = require('uuid'),
7070
httpStatusCodes = require('http-status'),
71-
Promise = require('bluebird');
71+
Promise = require('bluebird'),
72+
getRetryTimeout = require('./util/exponential-backoff');
7273

7374
// ------------------------------------------------------------------------------
7475
// Constants
@@ -100,6 +101,9 @@ var tokenPaths = {
100101
REVOKE: '/revoke'
101102
};
102103

104+
// Timer used to track elapsed time starting with executing an async request and ending with emitting the response.
105+
var asyncRequestTimer;
106+
103107
// The XFF header label - Used to give the API better information for uploads, rate-limiting, etc.
104108
const HEADER_XFF = 'X-Forwarded-For';
105109
const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
@@ -345,37 +349,92 @@ TokenManager.prototype = {
345349
try {
346350
assertion = jwt.sign(claims, keyParams, jwtOptions);
347351
} catch (jwtErr) {
348-
349352
return Promise.reject(jwtErr);
350353
}
351354

352355
var params = {
353356
grant_type: grantTypes.JWT,
354357
assertion
355358
};
359+
// Start the request timer immediately before executing the async request
360+
asyncRequestTimer = process.hrtime();
356361
return this.getTokens(params, options)
357-
.catch(err => {
358-
359-
// When a client's clock is out of sync with Box API servers, they'll get an error about the exp claim
360-
// In these cases, we can attempt to retry the grant request with a new exp claim calculated frem the
361-
// Date header sent by the server
362-
if (isJWTAuthErrorRetryable(err)) {
363-
364-
var serverTime = Math.floor(Date.parse(err.response.headers.date) / 1000);
365-
claims.exp = serverTime + this.config.appAuth.expirationTime;
366-
jwtOptions.jwtid = uuid.v4();
362+
.catch(err => this.retryJWTGrant(claims, jwtOptions, keyParams, params, options, err, 0));
363+
},
367364

368-
try {
369-
params.assertion = jwt.sign(claims, keyParams, jwtOptions);
370-
} catch (jwtErr) {
371-
throw jwtErr;
365+
/**
366+
* Attempt a retry if possible and create a new JTI claim. If the request hasn't exceeded it's maximum number of retries,
367+
* re-execute the request (after the retry interval). Otherwise, propagate a new error.
368+
*
369+
* @param {Object} claims - JTI claims object
370+
* @param {Object} [jwtOptions] - JWT options for the signature
371+
* @param {Object} keyParams - Key JWT parameters object that contains the private key and the passphrase
372+
* @param {Object} params - Should contain all params expected by Box OAuth2 token endpoint
373+
* @param {TokenRequestOptions} [options] - Sets optional behavior for the token grant
374+
* @param {Error} error - Error from the previous JWT request
375+
* @param {int} numRetries - Number of retries attempted
376+
* @returns {Promise<TokenInfo>} Promise resolving to the token info
377+
*/
378+
// eslint-disable-next-line max-params
379+
retryJWTGrant(claims, jwtOptions, keyParams, params, options, error, numRetries) {
380+
if (numRetries < this.config.numMaxRetries && isJWTAuthErrorRetryable(error)) {
381+
var retryTimeout;
382+
numRetries += 1;
383+
// If the retry strategy is defined, then use it to determine the time (in ms) until the next retry or to
384+
// propagate an error to the user.
385+
if (this.config.retryStrategy) {
386+
// Get the total elapsed time so far since the request was executed
387+
var totalElapsedTime = process.hrtime(asyncRequestTimer);
388+
var totalElapsedTimeMS = (totalElapsedTime[0] * 1000) + (totalElapsedTime[1] / 1000000);
389+
var retryOptions = {
390+
error,
391+
numRetryAttempts: numRetries,
392+
numMaxRetries: this.config.numMaxRetries,
393+
retryIntervalMS: this.config.retryIntervalMS,
394+
totalElapsedTimeMS
395+
};
396+
397+
retryTimeout = this.config.retryStrategy(retryOptions);
398+
399+
// If the retry strategy doesn't return a number/time in ms, then propagate the response error to the user.
400+
// However, if the retry strategy returns its own error, this will be propagated to the user instead.
401+
if (typeof retryTimeout !== 'number') {
402+
if (retryTimeout instanceof Error) {
403+
error = retryTimeout;
372404
}
373-
374-
return this.getTokens(params, options);
405+
throw error;
375406
}
407+
} else if (error.response.headers.hasOwnProperty('retry-after')) {
408+
retryTimeout = error.response.headers['retry-after'] * 1000;
409+
} else {
410+
retryTimeout = getRetryTimeout(numRetries, this.config.retryIntervalMS);
411+
}
412+
413+
var time = Math.floor(Date.now() / 1000);
414+
if (error.response.headers.date) {
415+
time = Math.floor(Date.parse(error.response.headers.date) / 1000);
416+
}
417+
// Add length of retry timeout to current expiration time to calculate the expiration time for the JTI claim.
418+
claims.exp = time + this.config.appAuth.expirationTime + (retryTimeout / 1000);
419+
jwtOptions.jwtid = uuid.v4();
376420

377-
throw err;
421+
try {
422+
params.assertion = jwt.sign(claims, keyParams, jwtOptions);
423+
} catch (jwtErr) {
424+
throw jwtErr;
425+
}
426+
427+
return Promise.delay(retryTimeout).then(() => {
428+
// Start the request timer immediately before executing the async request
429+
asyncRequestTimer = process.hrtime();
430+
return this.getTokens(params, options)
431+
.catch(err => this.retryJWTGrant(claims, jwtOptions, keyParams, params, options, err, numRetries));
378432
});
433+
} else if (numRetries >= this.config.numMaxRetries) {
434+
error.maxRetriesExceeded = true;
435+
}
436+
437+
throw error;
379438
},
380439

381440
/**

lib/util/exponential-backoff.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* @fileoverview Calculate exponential backoff time
3+
*/
4+
5+
'use strict';
6+
7+
// ------------------------------------------------------------------------------
8+
// Private
9+
// ------------------------------------------------------------------------------
10+
11+
// Retry intervals are between 50% and 150% of the exponentially increasing base amount
12+
const RETRY_RANDOMIZATION_FACTOR = 0.5;
13+
14+
/**
15+
* Calculate the exponential backoff time with randomized jitter
16+
* @param {int} numRetries Which retry number this one will be
17+
* @param {int} baseInterval The base retry interval set in config
18+
* @returns {int} The number of milliseconds after which to retry
19+
*/
20+
function getRetryTimeout(numRetries, baseInterval) {
21+
22+
var minRandomization = 1 - RETRY_RANDOMIZATION_FACTOR;
23+
var maxRandomization = 1 + RETRY_RANDOMIZATION_FACTOR;
24+
var randomization = (Math.random() * (maxRandomization - minRandomization)) + minRandomization;
25+
var exponential = Math.pow(2, numRetries - 1);
26+
return Math.ceil(exponential * baseInterval * randomization);
27+
}
28+
29+
module.exports = getRetryTimeout;

tests/lib/api-request-manager-test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ describe('APIRequestManager', function() {
9898
});
9999

100100
});
101+
101102
describe('makeRequest()', function() {
102103

103104
it('should pass the given options to the APIRequest constructor when called', function() {

0 commit comments

Comments
 (0)