Source: scripts/http.js

/*
 * Copyright (c) 2016-2017, Michael A. Updike All rights reserved.
 * Licensed under Apache 2.0
 * https://opensource.org/licenses/Apache-2.0
 * https://github.com/opus1269/chrome-extension-utils/blob/master/LICENSE.md
 */
window.Chrome = window.Chrome || {};

/**
 * Fetch with authentication and exponential back-off
 * @namespace
 */
Chrome.Http = (function() {
  'use strict';

  new ExceptionHandler();

  /**
   * Http configuration
   * @typedef {?{}} Chrome.Http.Config
   * @property {boolean} [isAuth=false] - if true, authorization required
   * @property {boolean} [retryToken=false] - if true, retry with new token
   * @property {boolean} [interactive=false] - user initiated, if true
   * @property {?string} [token=null] - auth token
   * @property {boolean} [backoff=true] - if true, do exponential back-off
   * @property {int} [maxRetries=_MAX_ATTEMPTS] - max retries
   * @memberOf Chrome.Http
   */

  /**
   * Http response
   * @typedef {{}} Chrome.Http.Response
   * @property {boolean} ok - success flag
   * @property {Function} json - returned data as JSON
   * @property {int} status - HTTP response code
   * @property {string} statusText - HTTP response message
   * @memberOf Chrome.Http
   */

  /**
   * Authorization header
   * @type {string}
   * @private
   * @memberOf Chrome.Http
   */
  const _AUTH_HEADER = 'Authorization';

  /**
   * Bearer parameter for authorized call
   * @type {string}
   * @private
   * @memberOf Chrome.Http
   */
  const _BEARER = 'Bearer';

  /**
   * Max retries on 500 errors
   * @type {int}
   * @private
   * @memberOf Chrome.Http
   */
  const _MAX_RETRIES = 4;

  /**
   * Delay multiplier for exponential back-off
   * @const
   * @private
   * @memberOf Chrome.Http
   */
  const _DELAY = 1000;

  /**
   * Configuration object
   * @type {Chrome.Http.Config}
   * @private
   * @memberOf Chrome.Http
   */
  const _CONFIG = {
    isAuth: false,
    retryToken: false,
    interactive: false,
    token: null,
    backoff: true,
    maxRetries: _MAX_RETRIES,
  };

  /**
   * Check response and act accordingly
   * @param {Chrome.Http.Response} response - server response
   * @param {string} url - server
   * @param {Object} opts - fetch options
   * @param {Chrome.Http.Config} conf - configuration
   * @param {int} attempt - the retry attempt we are on
   * @returns {Promise.<JSON>} response from server
   * @private
   * @memberOf Chrome.Http
   */
  function _processResponse(response, url, opts, conf, attempt) {
    if (response.ok) {
      // request succeeded - woo hoo!
      return response.json();
    }

    if (attempt >= conf.maxRetries) {
      // request still failed after maxRetries
      return Promise.reject(_getError(response));
    }

    const status = response.status;

    if (conf.backoff && (status >= 500) && (status < 600)) {
      // temporary server error, maybe. Retry with backoff
      return _retry(url, opts, conf, attempt);
    }

    if (conf.isAuth && conf.token && conf.retryToken && (status === 401)) {
      // could be expired token. Remove cached one and try again
      return _retryToken(url, opts, conf, attempt);
    }

    if (conf.isAuth && conf.interactive && conf.token && conf.retryToken &&
        (status === 403)) {
      // user may have revoked access to extension at some point
      // If interactive, retry so they can authorize again
      return _retryToken(url, opts, conf, attempt);
    }

    // request failed
    return Promise.reject(_getError(response));
  }

  /**
   * Get Error message
   * @param {Chrome.Http.Response} response - server response
   * @returns {Error}
   * @private
   * @memberOf Chrome.Http
   */
  function _getError(response) {
    let msg = 'Unknown error.';
    if (response && response.status &&
        (typeof(response.statusText) !== 'undefined')) {
      let statusMsg = Chrome.Locale.localize('err_status');
      if ((typeof(statusMsg) === 'undefined') || (statusMsg === '')) {
        // in case localize is missing
        statusMsg = 'Status';
      }
      msg = `${statusMsg}: ${response.status}`;
      msg += `\n${response.statusText}`;
    }
    return new Error(msg);
  }

  /**
   * Get authorization token
   * @param {boolean} isAuth - if true, authorization required
   * @param {boolean} interactive - if true, user initiated
   * @returns {Promise.<string>} auth token
   * @private
   * @memberOf Chrome.Http
   */
  function _getAuthToken(isAuth, interactive) {
    if (isAuth) {
      return Chrome.Auth.getToken(interactive).then((token) => {
        return Promise.resolve(token);
      }).catch((err) => {
        if (interactive && (err.message.includes('revoked') ||
            err.message.includes('Authorization page could not be loaded'))) {
          // try one more time non-interactively
          // Always returns Authorization page error
          // when first registering, Not sure why
          // Other message is if user revoked access to extension
          return Chrome.Auth.getToken(false);
        } else {
          return Promise.reject(err);
        }
      });
    } else {
      // non-authorization branch
      return Promise.resolve(null);
    }
  }

  /**
   * Retry authorized fetch with exponential back-off
   * @param {string} url - server request
   * @param {Object} opts - fetch options
   * @param {Chrome.Http.Config} conf - configuration
   * @param {int} attempt - the retry attempt we are on
   * @returns {Promise.<JSON>} response from server
   * @private
   * @memberOf Chrome.Http
   */
  function _retry(url, opts, conf, attempt) {
    attempt++;
    // eslint-disable-next-line promise/avoid-new
    return new Promise((resolve, reject) => {
      const delay = (Math.pow(2, attempt) - 1) * _DELAY;
      setTimeout(() => {
        return _fetch(url, opts, conf, attempt).then(resolve, reject);
      }, delay);
    });
  }

  /**
   * Retry fetch after removing cached auth token
   * @param {string} url - server request
   * @param {Object} opts - fetch options
   * @param {Chrome.Http.Config} conf - configuration
   * @param {int} attempt - the retry attempt we are on
   * @returns {Promise.<JSON>} response from server
   * @private
   * @memberOf Chrome.Http
   */
  function _retryToken(url, opts, conf, attempt) {
    Chrome.GA.error('Refreshed auth token.', 'Http._retryToken');
    return Chrome.Auth.removeCachedToken(conf.token).then(() => {
      conf.token = null;
      conf.retryToken = false;
      return _fetch(url, opts, conf, attempt);
    });
  }

  /**
   * Perform fetch, optionally using authorization and exponential back-off
   * @param {string} url - server request
   * @param {Object} opts - fetch options
   * @param {Chrome.Http.Config} conf - configuration
   * @param {int} attempt - the retry attempt we are on
   * @returns {Promise.<JSON>} response from server
   * @private
   * @memberOf Chrome.Http
   */
  function _fetch(url, opts, conf, attempt) {
    return _getAuthToken(conf.isAuth, conf.interactive).then((authToken) => {
      if (conf.isAuth) {
        conf.token = authToken;
        opts.headers.set(_AUTH_HEADER, `${_BEARER} ${conf.token}`);
      }
      return fetch(url, opts);
    }).then((response) => {
      return _processResponse(response, url, opts, conf, attempt);
    }).catch((err) => {
      let msg = err.message;
      if (msg === 'Failed to fetch') {
        msg = Chrome.Locale.localize('err_network');
        if ((typeof(msg) === 'undefined') || (msg === '')) {
          // in case localize is missing
          msg = 'Network error';
        }
      }
      return Promise.reject(new Error(msg));
    });
  }

  /**
   * Do a server request
   * @param {string} url - server request
   * @param {Object} opts - fetch options
   * @param {Chrome.Http.Config} conf - configuration
   * @returns {Promise.<JSON>} response from server
   * @private
   * @memberOf Chrome.Http
   */
  function _doIt(url, opts, conf) {
    conf = (conf === null) ? _CONFIG : conf;
    if (conf.isAuth) {
      opts.headers.set(_AUTH_HEADER, `${_BEARER} unknown`);
    }
    let attempt = 0;
    return _fetch(url, opts, conf, attempt);
  }

  return {
    conf: _CONFIG,

    /**
     * Perform GET request
     * @param {string} url - server request
     * @param {Chrome.Http.Config} [conf=null] - configuration
     * @returns {Promise.<JSON>} response from server
     * @memberOf Chrome.Http
     */
    doGet: function(url, conf = null) {
      const opts = {method: 'GET', headers: new Headers({})};
      return _doIt(url, opts, conf);
    },

    /**
     * Perform POST request
     * @param {string} url - server request
     * @param {Chrome.Http.Config} [conf=null] - configuration
     * @returns {Promise.<JSON>} response from server
     * @memberOf Chrome.Http
     */
    doPost: function(url, conf = null) {
      const opts = {method: 'POST', headers: new Headers({})};
      return _doIt(url, opts, conf);
    },
  };
})(window);