5353 */
5454function 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.
104108const HEADER_XFF = 'X-Forwarded-For' ;
105109const 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 /**
0 commit comments