Source: common/filters/retrypolicyfilter.js

// 
// Copyright (c) Microsoft and contributors.  All rights reserved.
// 
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//   http://www.apache.org/licenses/LICENSE-2.0
// 
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// 
// See the License for the specific language governing permissions and
// limitations under the License.
// 

var azureutil = require('../util/util');
var Constants = require('../util/constants');
var StorageUtilities = require('../util/storageutilities');
var extend = require('util')._extend;

/**
* Creates a new RetryPolicyFilter instance.
* @class
* The RetryPolicyFilter allows you to retry operations,
* using a custom retry policy. Users are responsible to 
* define the shouldRetry method.
* To apply a filter to service operations, use `withFilter`
* and specify the filter to be used when creating a service.
* @constructor
* @param {number} [retryCount=30000]        The client retry count.
* @param {number} [retryInterval=3]     The client retry interval, in milliseconds.
*
* @example
* var azure = require('azure-storage');
* var retryPolicy = new azure.RetryPolicyFilter();
* retryPolicy.retryCount = 3;
* retryPolicy.retryInterval = 3000;
* retryPolicy.shouldRetry = function(statusCode, retryContext) {
*
* };
* var blobService = azure.createBlobService().withFilter(retryPolicy);
*/
function RetryPolicyFilter(retryCount, retryInterval) {
  this.retryCount = retryCount ? retryCount : RetryPolicyFilter.DEFAULT_CLIENT_RETRY_COUNT;
  this.retryInterval = retryInterval ? retryInterval : RetryPolicyFilter.DEFAULT_CLIENT_RETRY_INTERVAL;
}

/**
* Represents the default client retry interval, in milliseconds.
*/
RetryPolicyFilter.DEFAULT_CLIENT_RETRY_INTERVAL = 1000 * 30;

/**
* Represents the default client retry count.
*/
RetryPolicyFilter.DEFAULT_CLIENT_RETRY_COUNT = 3;

/**
* Handles an operation with a retry policy.
*
* @param {Object}   requestOptions The original request options.
* @param {function} next           The next filter to be handled.
*/
RetryPolicyFilter.prototype.handle = function (requestOptions, next) {
  RetryPolicyFilter._handle(this, requestOptions, next);
};

/**
* Handles an operation with a retry policy.
*
* @param {Object}   requestOptions The original request options.
* @param {function} next           The next filter to be handled.
*/
RetryPolicyFilter._handle = function (self, requestOptions, next) {

  var retryRequestOptions = extend({}, requestOptions);

  // Initialize retryContext because that will be passed to the shouldRetry method which users will implement 
  retryRequestOptions.retryContext = {
    retryCount: 0,
    error: null,
    retryInterval: retryRequestOptions.retryInterval,
    locationMode: retryRequestOptions.locationMode,
    currentLocation: retryRequestOptions.currentLocation
  };

  var lastPrimaryAttempt;
  var lastSecondaryAttempt;
  var operation = function () {
    // retry policies dont really do anything to the request options
    // so move on to next
    if (next) {
      next(retryRequestOptions, function (returnObject, finalCallback, nextPostCallback) {
        // Previous operation ended so update the retry data
        if (returnObject.error) {
          if (retryRequestOptions.retryContext.error) {
            returnObject.error.innerError = retryRequestOptions.retryContext.error;
          }

          retryRequestOptions.retryContext.error = returnObject.error;
        }

        // If a request sent to the secondary location fails with 404 (Not Found), it is possible
        // that the resource replication is not finished yet. So, in case of 404 only in the secondary
        // location, the failure should still be retryable.
        var secondaryNotFound = (retryRequestOptions.currentLocation === Constants.StorageLocation.SECONDARY) && ((returnObject.response && returnObject.response.statusCode === 404) || (returnObject.error && returnObject.error.code === 'ENOTFOUND'));
 
        var notExceedMaxRetryCount = retryRequestOptions.retryContext.retryCount ? retryRequestOptions.retryContext.retryCount <= self.retryCount : true;
        var retryInfo = self.shouldRetry(secondaryNotFound ? 500 : (azureutil.objectIsNull(returnObject.response) ? 306 : returnObject.response.statusCode), retryRequestOptions);
        retryRequestOptions.retryContext.retryCount++;

        if (retryInfo.ignore) {
          returnObject.error = null;
        }

        // If the custom retry logic(shouldRetry) does not return a targetLocation, calculate based on the previous location and locationMode.
        if(azureutil.objectIsNull(retryInfo.targetLocation)) {
          retryInfo.targetLocation = azureutil.getNextLocation(retryRequestOptions.currentLocation, retryRequestOptions.locationMode);
        }

        // If the custom retry logic(shouldRetry) does not return a retryInterval, try to set it to the value on the instance if it is available. Otherwise, the default(30000) will be used.
        if(azureutil.objectIsNull(retryInfo.retryInterval)) {
          retryInfo.retryInterval = self.retryInterval;
        }
        
        // Only in the case of success from server but client side failure like MD5 or length mismatch, returnObject.retryable has a value(we explicitly set it to false). 
        // In this case, we should not retry the request.
        // If the output stream already get sent to server and get error back, 
        // we should NOT retry within the SDK as the stream data is not valid anymore if we retry directly.
        if (
            !returnObject.outputStreamSent && returnObject.error && azureutil.objectIsNull(returnObject.retryable) && notExceedMaxRetryCount &&
            (
              (!azureutil.objectIsNull(returnObject.response) && retryInfo.retryable) || 
              (
                returnObject.error.code === 'ECONNREFUSED' ||
                returnObject.error.code === 'ETIMEDOUT' ||
                returnObject.error.code === 'ESOCKETTIMEDOUT' || 
                returnObject.error.code === 'ECONNRESET' || 
                returnObject.error.code === 'EAI_AGAIN' ||
                returnObject.error.message === 'XHR error' // stream-http XHR network error message in browsers
              )
            )
          ) {
          if (retryRequestOptions.currentLocation === Constants.StorageLocation.PRIMARY) {
            lastPrimaryAttempt = returnObject.operationEndTime;
          } else {
            lastSecondaryAttempt = returnObject.operationEndTime;
          }

          // Moreover, in case of 404 when trying the secondary location, instead of retrying on the
          // secondary, further requests should be sent only to the primary location, as it most
          // probably has a higher chance of succeeding there.
          if (secondaryNotFound && (retryRequestOptions.locationMode !== StorageUtilities.LocationMode.SECONDARY_ONLY))
          {
            retryInfo.locationMode = StorageUtilities.LocationMode.PRIMARY_ONLY;
            retryInfo.targetLocation = Constants.StorageLocation.PRIMARY;
          }

          // Now is the time to calculate the exact retry interval. ShouldRetry call above already
          // returned back how long two requests to the same location should be apart from each other.
          // However, for the reasons explained above, the time spent between the last attempt to
          // the target location and current time must be subtracted from the total retry interval
          // that ShouldRetry returned.
          var lastAttemptTime = retryInfo.targetLocation === Constants.StorageLocation.PRIMARY ? lastPrimaryAttempt : lastSecondaryAttempt;
          if (!azureutil.objectIsNull(lastAttemptTime)) {
            var sinceLastAttempt = new Date().getTime() - lastAttemptTime.getTime();
            if (sinceLastAttempt < 0) {
              sinceLastAttempt = 0;
            }

            retryRequestOptions.retryInterval = retryInfo.retryInterval - sinceLastAttempt;
          }
          else {
            retryRequestOptions.retryInterval = 0;
          }

          if(!azureutil.objectIsNull(retryInfo.locationMode)) {
            retryRequestOptions.locationMode = retryInfo.locationMode;
          }

          retryRequestOptions.currentLocation = retryInfo.targetLocation;
          operation();
        } else {
          if (nextPostCallback) {
            nextPostCallback(returnObject);
          } else if (finalCallback) {
            finalCallback(returnObject);
          }
        }
      });
    }
  };

  operation();
};

RetryPolicyFilter._shouldRetryOnError = function (statusCode, requestOptions) {
  var retryInfo = (requestOptions && requestOptions.retryContext) ? requestOptions.retryContext : {};

  // Non-timeout Cases
  if (statusCode >= 300 && statusCode != 408) {
    // Always no retry on "not implemented" and "version not supported"
    if (statusCode == 501 || statusCode == 505) {
      retryInfo.retryable = false;
      return retryInfo;
    }
    
    // When absorbConditionalErrorsOnRetry is set (for append blob)
    if (requestOptions && requestOptions.absorbConditionalErrorsOnRetry) {
      if (statusCode == 412) {
        // When appending block with precondition failure and their was a server error before, we ignore the error.
        if (retryInfo.lastServerError) {
          retryInfo.ignore = true;
          retryInfo.retryable = true;
        } else {
          retryInfo.retryable = false;
        }
      } else if (statusCode >= 500 && statusCode < 600) {
        // Retry on the server error
        retryInfo.retryable = true;
        retryInfo.lastServerError = true;
      }
    } else if (statusCode < 500) {
      // No retry on the client error
      retryInfo.retryable = false;
    }
  }

  return retryInfo;
};

module.exports = RetryPolicyFilter;