Rate limiting Response handling pseudo code

Hi @OndejMedek ,

I just wrote a little NodeJS code to check if my pseudo code makes sense.

const fetch = require('node-fetch');

// See also https://developer.atlassian.com/cloud/jira/platform/rate-limiting

const defaultRetryOptions = {
  maxRetries: 4,
  lastRetryDelayMillis: 5000,
  maxRetryDelayMillis: 30000,
  jitterMultiplierRange: [0.7, 1.3]
}

const apiFetch = {};

/**
 * This method mimics node-fetch, but allows certain options to be passed in to 
 * control how to handle errors.
 * @param {*} url the URL to be called.
 * @param {*} fetchOptions the options as per node-fetch
 * @param {*} retryOptions an object comprising fields maxRetries, lastRetryDelayMillis, 
 * maxRetryDelayMillis and jitterMultiplierRange. See defaultRetryOptions above.
 * @returns the response of the API call.
 */
apiFetch.fetch = async function (url, fetchOptions, retryOptions) {
  const retryCount = 0;
  retryOptions = apiFetch._sanitiseRetryOptions(retryOptions);
  return await apiFetch._fetch(url, fetchOptions, retryOptions, retryCount);
}

apiFetch._fetch = function (url, fetchOptions, retryOptions, retryCount) {
  return new Promise(async (resolve, reject) => {
    try {
      const response = await fetch(url, fetchOptions);
      if (response.ok) {
        resolve(response);
      } else {
        let retryDelayMillis = -1;
        let retryAfter = response.headers.get('Retry-After');
        if (retryAfter) {
          retryDelayMillis = 1000 * parseInt(retryAfter);
          console.log(`* Retry-After header value = ${retryAfter}, retryDelayMillis = ${retryDelayMillis}, retryCount = ${retryCount}`);
        } else if (response.status === 429) {
          retryDelayMillis = Math.min(2 * retryOptions.lastRetryDelayMillis, retryOptions.maxRetryDelayMillis);
          console.log(`* Rate limited without Retry-After header! retryDelayMillis = ${retryDelayMillis}, retryCount = ${retryCount}`);
        }
        if (retryDelayMillis > 0 && retryCount < retryOptions.maxRetries) {
          retryDelayMillis += retryDelayMillis * apiFetch._randomInRange(retryOptions.jitterMultiplierRange[0], retryOptions.jitterMultiplierRange[1]);
          await apiFetch._delay(retryDelayMillis);
          resolve(apiFetch._fetch(url, fetchOptions, retryOptions, retryCount + 1));
        } else {
          console.log(`Giving up and sending response with status ${response.status} (retry count = ${retryCount})...`);
          resolve(response);
        }
      }
    } catch (error) {
      console.log(`Caught error:`, error);
      reject(error);
    }
  });
}

apiFetch._delay = async function (millis) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve();
    }, millis);
  });
}

apiFetch._sanitiseRetryOptions = function (retryOptions) {
  retryOptions = retryOptions ? retryOptions : {};
  const sanitisedOptions = Object.assign({}, retryOptions, defaultRetryOptions);
  if (sanitisedOptions.jitterMultiplierRange[1] <= sanitisedOptions.jitterMultiplierRange[0]) {
    throw new Error(`jitterMultiplierRange must be an array with the second number beiong larger than the first.`);
  }
  return sanitisedOptions;
}

apiFetch._randomInRange = function (min, max) {
  return Math.random() * (max - min) + min;
}

module.exports = apiFetch;

I gave it a little test and it seems to work, but definitely do additional testing. I hope it helps.

Regards,
Dugald

7 Likes