Source: src/js/models/dataONEServices/HttpRetryPolicy.js

define([], () => {
  // Server error codes that should be retried by default: 408: Request Timeout
  // 429: Too Many Requests 500: Internal Server Error 502: Bad Gateway 503:
  // Service Unavailable 504: Gateway Timeout
  const DEFAULT_RETRY_ON = [408, 429, 500, 502, 503, 504];

  // Default retry configuration
  const DEFAULT_RETRY = {
    maxRetries: 2,
    baseDelayMs: 250,
    maxDelayMs: 4000,
    retryOn: DEFAULT_RETRY_ON,
    retryNetworkErrors: true,
  };

  // Convert a value to a non-negative integer, or return the fallback.
  const toNonNegativeInt = (value, fallback) => {
    const num = Number(value);
    if (!Number.isFinite(num)) return fallback;
    if (num < 0) return 0;
    return Math.floor(num);
  };

  // Convert a value to a non-negative number, or return the fallback.
  const toNonNegativeNumber = (value, fallback) => {
    const num = Number(value);
    if (!Number.isFinite(num)) return fallback;
    if (num < 0) return 0;
    return num;
  };

  /**
   * @class HttpRetryPolicy
   * @classdesc Configuration and logic for HTTP request retry policies.
   * @since 2.37.0
   * @example
   * const retryPolicy = new HttpRetryPolicy({
   *   maxRetries: 3,
   *   baseDelayMs: 500,
   *   retryOn: [500, 502, 503, 504],
   *   retryNetworkErrors: true,
   * });
   * retryPolicy.shouldRetry({
   *   attempt: 1,
   *   status: 500,
   *   isNetworkError: false,
   * });
   * // returns true
   */
  class HttpRetryPolicy {
    /**
     * @param {object} [options] Retry policy configuration.
     * @param {number} [options.maxRetries] Number of retry attempts (excludes
     * initial attempt).
     * @param {number} [options.baseDelayMs] Initial backoff delay in
     * milliseconds.
     * @param {number} [options.maxDelayMs] Maximum backoff delay in
     * milliseconds. Set to `null` to disable capping or 0 to retry immediately.
     * @param {number[]} [options.retryOn] Status codes that should be retried.
     * @param {boolean} [options.retryNetworkErrors] Whether to retry network
     * errors (such DNS failures or connection timeouts) when the status code is
     * not available.
     * @param {Function} [options.randomFn] Function used for jitter (useful for
     * testing). Should return a number between 0 and 1.
     */
    constructor(options = {}) {
      const {
        maxRetries = DEFAULT_RETRY.maxRetries,
        baseDelayMs = DEFAULT_RETRY.baseDelayMs,
        maxDelayMs = DEFAULT_RETRY.maxDelayMs,
        retryOn = DEFAULT_RETRY.retryOn,
        retryNetworkErrors = DEFAULT_RETRY.retryNetworkErrors,
        randomFn = Math.random,
      } = options || {};

      this.maxRetries = toNonNegativeInt(maxRetries, DEFAULT_RETRY.maxRetries);
      this.baseDelayMs = toNonNegativeNumber(
        baseDelayMs,
        DEFAULT_RETRY.baseDelayMs,
      );
      this.maxDelayMs =
        maxDelayMs === null
          ? null
          : toNonNegativeNumber(maxDelayMs, DEFAULT_RETRY.maxDelayMs);
      let normalizedRetryOn = Array.isArray(retryOn) ? [...retryOn] : [];
      normalizedRetryOn = normalizedRetryOn
        .map((code) => Number(code))
        .filter((code) => Number.isFinite(code) && code >= 100 && code <= 599);
      this.retryOn = normalizedRetryOn;
      this.retryNetworkErrors = retryNetworkErrors === true;
      this.randomFn = typeof randomFn === "function" ? randomFn : Math.random;
    }

    /**
     * Maximum number of attempts including the initial request.
     * @returns {number} Maximum attempts.
     */
    get maxAttempts() {
      return Math.max(1, this.maxRetries + 1);
    }

    /**
     * Normalize a status code to a valid HTTP status code number. Converts
     * strings to numbers. Returns `null` for invalid codes.
     * @param {number|string} status HTTP status code.
     * @returns {number|null} Normalized status code or `null` when invalid.
     */
    static normalizeStatus(status) {
      const code = Number(status);
      if (!Number.isFinite(code)) return null;
      if (code < 100 || code > 599) return null;
      return code;
    }

    /**
     * Create a new HttpRetryPolicy with some overridden options.
     * @param {object} overrides Options to override. See constructor for
     * details.
     * @returns {HttpRetryPolicy} New HttpRetryPolicy instance.
     */
    withOverrides(overrides = {}) {
      return new HttpRetryPolicy({
        maxRetries:
          overrides.maxRetries !== undefined
            ? overrides.maxRetries
            : this.maxRetries,
        baseDelayMs:
          overrides.baseDelayMs !== undefined
            ? overrides.baseDelayMs
            : this.baseDelayMs,
        maxDelayMs:
          overrides.maxDelayMs !== undefined
            ? overrides.maxDelayMs
            : this.maxDelayMs,
        retryOn:
          overrides.retryOn !== undefined ? overrides.retryOn : this.retryOn,
        retryNetworkErrors:
          overrides.retryNetworkErrors !== undefined
            ? overrides.retryNetworkErrors
            : this.retryNetworkErrors,
        randomFn:
          overrides.randomFn !== undefined ? overrides.randomFn : this.randomFn,
      });
    }

    /**
     * Determine if a status code is configured as retryable.
     * @param {number} status HTTP status code.
     * @param {number[]} [retryOn] Override array of status codes to check.
     * @returns {boolean} `true` when the status code should be retried.
     */
    shouldRetryStatus(status, retryOn = this.retryOn) {
      const normalizedStatus = this.constructor.normalizeStatus(status);
      if (!normalizedStatus) return false;
      if (!Array.isArray(retryOn)) return false;
      return retryOn.includes(normalizedStatus);
    }

    /**
     * Parse a `Retry-After` header into a millisecond delay. Accepts either a
     * Headers-like object with `.get()` or a plain object with a `Retry-After`
     * property.
     * @param {Headers|object|null} headers The headers returned from the
     * server. Can be a `Headers` instance or a plain object. Null/undefined if
     * no headers are available.
     * @param {number} [maxDelayMs] Override maximum delay cap in milliseconds.
     * @returns {number|null} Millisecond delay or `null` when the header is
     * missing or invalid.
     */
    parseRetryAfter(headers, maxDelayMs = this.maxDelayMs) {
      if (!headers) return null;
      let retryAfter = null;
      if (typeof headers.get === "function") {
        retryAfter = headers.get("Retry-After");
      } else if (typeof headers === "object") {
        retryAfter = headers["Retry-After"] ?? headers["retry-after"] ?? null;
      }
      // No Retry-After header
      if (!retryAfter) return null;
      // Retry-After can be either an integer number of seconds or a HTTP date.
      // First, try parsing as an integer number of seconds.
      const asInt = parseInt(retryAfter, 10);
      if (!Number.isNaN(asInt)) {
        const millis = asInt * 1000;
        return maxDelayMs !== null && maxDelayMs !== undefined
          ? Math.min(millis, maxDelayMs)
          : millis;
      }
      // Next, try parsing as a HTTP date.
      const dateDelay = Date.parse(retryAfter) - Date.now();
      if (dateDelay > 0) {
        return maxDelayMs !== null && maxDelayMs !== undefined
          ? Math.min(dateDelay, maxDelayMs)
          : dateDelay;
      }
      // Invalid Retry-After header
      return null;
    }

    /**
     * Compute the delay before the next retry attempt using exponential backoff
     * (double the delay after each failed attempt) and jitter.
     * @param {object} params Parameters object.
     * @param {number} params.attempt Current attempt number (1-based). Values
     * <1 are treated as the first attempt.
     * @param {number|null} [params.retryAfterMs] Explicit delay that overrides
     * backoff (e.g., already parsed from headers elsewhere) and takes
     * precedence over the `Retry-After` header.
     * @param {Headers|object|null} [params.headers] Optional headers object to
     * check for a `Retry-After` header. If present and valid, its value will be
     * used as the delay and will override the exponential backoff calculation.
     * @param {number} [params.baseDelayMs] Optional base delay override for the
     * backoff calculation.
     * @param {number|null} [params.maxDelayMs] Optional maximum delay cap; set
     * to null to disable capping.
     * @returns {number} Delay in milliseconds before the next retry attempt.
     */
    computeDelay({
      attempt,
      retryAfterMs = null,
      headers,
      baseDelayMs = this.baseDelayMs,
      maxDelayMs = this.maxDelayMs,
    } = {}) {
      // If valid retryAfterMs is provided, use it
      const retryAfterIsValid =
        retryAfterMs !== null &&
        retryAfterMs !== undefined &&
        Number.isFinite(retryAfterMs) &&
        retryAfterMs >= 0;
      if (retryAfterIsValid) {
        return retryAfterMs;
      }

      // Next, check for a Retry-After header
      const parsedRetryAfter = this.parseRetryAfter(headers, maxDelayMs);
      if (parsedRetryAfter !== null && parsedRetryAfter !== undefined) {
        return parsedRetryAfter;
      }

      // Otherwise, compute exponential backoff with jitter
      const standardizedBaseDelayMs = toNonNegativeNumber(
        baseDelayMs,
        this.baseDelayMs,
      );
      const standardizedMaxDelayMs = toNonNegativeNumber(
        maxDelayMs,
        this.maxDelayMs,
      );
      const attemptNumber = Math.max(1, Number(attempt) || 0);
      const exponential =
        standardizedBaseDelayMs * 2 ** Math.max(0, attemptNumber - 1);
      const capped =
        standardizedMaxDelayMs !== null && standardizedMaxDelayMs !== undefined
          ? Math.min(exponential, standardizedMaxDelayMs)
          : exponential;

      const rawRandom = Number(this.randomFn());
      const normalizedRandom = Number.isFinite(rawRandom)
        ? Math.min(1, Math.max(0, rawRandom))
        : 0.5;
      const jitter = capped * 0.2 * (normalizedRandom * 2 - 1);

      return Math.max(0, capped + jitter);
    }

    /**
     * Decide whether a request should be retried based on attempt count, status
     * code, and network errors.
     * @param {object} params Parameters object.
     * @param {number} params.attempt Current attempt number (1-based).
     * @param {number} [params.maxAttempts] Override the maximum attempts.
     * @param {number|string|null} params.status HTTP status code from the
     * response (if any).
     * @param {boolean} params.isNetworkError Whether the last attempt failed
     * due to a network issue.
     * @returns {boolean} `true` when the request should be retried.
     */
    shouldRetry({
      attempt,
      maxAttempts = this.maxAttempts,
      status,
      isNetworkError,
    } = {}) {
      const attemptNumber = Number.isFinite(attempt) ? attempt : 0;
      if (attemptNumber >= maxAttempts) return false;

      if (isNetworkError) {
        return this.retryNetworkErrors === true;
      }
      // some backbone versions pass status as a string
      const normalizedStatus = this.constructor.normalizeStatus(status);
      if (normalizedStatus) {
        return this.shouldRetryStatus(normalizedStatus, this.retryOn);
      }
      return false;
    }
  }

  HttpRetryPolicy.DEFAULT_RETRY_ON = DEFAULT_RETRY_ON;
  HttpRetryPolicy.DEFAULT_RETRY = DEFAULT_RETRY;

  return HttpRetryPolicy;
});