From 5fc0c8f3935245e812214b5e28f1a16508e7d5ae Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Mon, 28 Aug 2023 13:23:36 -0700 Subject: [PATCH] chore: migrate from gts to xo (#222) --- .eslintignore | 3 - .eslintrc.json | 3 - .prettierignore | 3 - .prettierrc.json | 6 - package.json | 17 +- src/index.ts | 575 ++++++++++---------- test/index.ts | 1307 ++++++++++++++++++++++++---------------------- tsconfig.json | 3 +- 8 files changed, 986 insertions(+), 931 deletions(-) delete mode 100644 .eslintignore delete mode 100644 .eslintrc.json delete mode 100644 .prettierignore delete mode 100644 .prettierrc.json diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 4a86f7e..0000000 --- a/.eslintignore +++ /dev/null @@ -1,3 +0,0 @@ -**/node_modules -build/ -dist/ diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 7821534..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "./node_modules/gts" -} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 1bf0471..0000000 --- a/.prettierignore +++ /dev/null @@ -1,3 +0,0 @@ -coverage/ -build/ -**/node_modules \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index 993c327..0000000 --- a/.prettierrc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "bracketSpacing": false, - "singleQuote": true, - "trailingComma": "es5", - "arrowParens": "avoid" -} diff --git a/package.json b/package.json index 08a6bc3..cc7d7e1 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,8 @@ "url": "https://github.com/JustinBeckwith/retry-axios.git" }, "scripts": { - "lint": "gts check", - "clean": "gts clean", - "fix": "gts fix", + "lint": "xo --prettier", + "fix": "xo --prettier --fix", "compile": "tsc -p .", "test": "c8 mocha build/test", "pretest": "npm run compile", @@ -34,17 +33,17 @@ }, "devDependencies": { "@types/mocha": "^10.0.1", - "@types/sinon": "^10.0.13", "@types/node": "^18.15.9", + "@types/sinon": "^10.0.13", "axios": "^1.2.1", "c8": "^8.0.0", - "gts": "^3.1.1", "js-green-licenses": "^4.0.0", "mocha": "^10.2.0", "nock": "^13.3.0", "semantic-release": "^21.0.0", "sinon": "^15.0.2", - "typescript": "~5.1.0" + "typescript": "~5.1.0", + "xo": "^0.56.0" }, "files": [ "build/src" @@ -54,5 +53,11 @@ "build/test", "dist" ] + }, + "xo": { + "rules": { + "@typescript-eslint/prefer-nullish-coalescing": "off", + "@typescript-eslint/consistent-type-definitions": "off" + } } } diff --git a/src/index.ts b/src/index.ts index 5a45321..e367dde 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,84 +1,85 @@ import axios, { - AxiosError, - AxiosInstance, - AxiosRequestConfig, - AxiosResponse, + type AxiosError, + type AxiosInstance, + type AxiosRequestConfig, + type AxiosResponse, + isCancel, } from 'axios'; /** * Configuration for the Axios `request` method. */ export interface RetryConfig { - /** - * The number of times to retry the request. Defaults to 3. - */ - retry?: number; - - /** - * The number of retries already attempted. - */ - currentRetryAttempt?: number; - - /** - * The amount of time to initially delay the retry. Defaults to 100. - */ - retryDelay?: number; - - /** - * The instance of the axios object to which the interceptor is attached. - */ - instance?: AxiosInstance; - - /** - * The HTTP Methods that will be automatically retried. - * Defaults to ['GET','PUT','HEAD','OPTIONS','DELETE'] - */ - httpMethodsToRetry?: string[]; - - /** - * The HTTP response status codes that will automatically be retried. - * Defaults to: [[100, 199], [429, 429], [500, 599]] - */ - statusCodesToRetry?: number[][]; - - /** - * Function to invoke when a retry attempt is made. - */ - onRetryAttempt?: (err: AxiosError) => void; - - /** - * Function to invoke which determines if you should retry - */ - shouldRetry?: (err: AxiosError) => boolean; - - /** - * When there is no response, the number of retries to attempt. Defaults to 2. - */ - noResponseRetries?: number; - - /** - * Backoff Type; 'linear', 'static' or 'exponential'. - */ - backoffType?: 'linear' | 'static' | 'exponential'; - - /** - * Whether to check for 'Retry-After' header in response and use value as delay. Defaults to true. - */ - checkRetryAfter?: boolean; - - /** - * Max permitted Retry-After value (in ms) - rejects if greater. Defaults to 5 mins. - */ - maxRetryAfter?: number; - - /** - * Ceiling for calculated delay (in ms) - delay will not exceed this value. - */ - maxRetryDelay?: number; + /** + * The number of times to retry the request. Defaults to 3. + */ + retry?: number; + + /** + * The number of retries already attempted. + */ + currentRetryAttempt?: number; + + /** + * The amount of time to initially delay the retry. Defaults to 100. + */ + retryDelay?: number; + + /** + * The instance of the axios object to which the interceptor is attached. + */ + instance?: AxiosInstance; + + /** + * The HTTP Methods that will be automatically retried. + * Defaults to ['GET','PUT','HEAD','OPTIONS','DELETE'] + */ + httpMethodsToRetry?: string[]; + + /** + * The HTTP response status codes that will automatically be retried. + * Defaults to: [[100, 199], [429, 429], [500, 599]] + */ + statusCodesToRetry?: number[][]; + + /** + * Function to invoke when a retry attempt is made. + */ + onRetryAttempt?: (error: AxiosError) => void; + + /** + * Function to invoke which determines if you should retry + */ + shouldRetry?: (error: AxiosError) => boolean; + + /** + * When there is no response, the number of retries to attempt. Defaults to 2. + */ + noResponseRetries?: number; + + /** + * Backoff Type; 'linear', 'static' or 'exponential'. + */ + backoffType?: 'linear' | 'static' | 'exponential'; + + /** + * Whether to check for 'Retry-After' header in response and use value as delay. Defaults to true. + */ + checkRetryAfter?: boolean; + + /** + * Max permitted Retry-After value (in ms) - rejects if greater. Defaults to 5 mins. + */ + maxRetryAfter?: number; + + /** + * Ceiling for calculated delay (in ms) - delay will not exceed this value. + */ + maxRetryDelay?: number; } export type RaxConfig = { - raxConfig: RetryConfig; + raxConfig: RetryConfig; } & AxiosRequestConfig; /** @@ -88,8 +89,8 @@ export type RaxConfig = { * @returns The id of the interceptor attached to the axios instance. */ export function attach(instance?: AxiosInstance) { - instance = instance || axios; - return instance.interceptors.response.use(onFulfilled, onError); + instance = instance || axios; + return instance.interceptors.response.use(onFulfilled, onError); } /** @@ -98,12 +99,12 @@ export function attach(instance?: AxiosInstance) { * @param instance The axios instance using this interceptor. */ export function detach(interceptorId: number, instance?: AxiosInstance) { - instance = instance || axios; - instance.interceptors.response.eject(interceptorId); + instance = instance || axios; + instance.interceptors.response.eject(interceptorId); } -function onFulfilled(res: AxiosResponse) { - return res; +function onFulfilled(result: AxiosResponse) { + return result; } /** @@ -121,22 +122,25 @@ function onFulfilled(res: AxiosResponse) { * @param obj The object that (may) have integers that correspond to an index * @returns An array with the pucked values */ -function normalizeArray(obj?: T[]): T[] | undefined { - const arr: T[] = []; - if (!obj) { - return undefined; - } - if (Array.isArray(obj)) { - return obj; - } - if (typeof obj === 'object') { - Object.keys(obj).forEach(key => { - if (typeof key === 'number') { - arr[key] = obj[key]; - } - }); - } - return arr; +function normalizeArray(object?: T[]): T[] | undefined { + const array: T[] = []; + if (!object) { + return undefined; + } + + if (Array.isArray(object)) { + return object; + } + + if (typeof object === 'object') { + for (const key of Object.keys(object)) { + if (typeof key === 'number') { + array[key] = object[key]; + } + } + } + + return array; } /** @@ -146,212 +150,219 @@ function normalizeArray(obj?: T[]): T[] | undefined { * @returns Number of milliseconds, or undefined if invalid */ function parseRetryAfter(header: string): number | undefined { - // Header value may be string containing integer seconds - const value = Number(header); - if (!Number.isNaN(value)) { - return value * 1000; - } - // Or HTTP date time string - const dateTime = Date.parse(header); - if (!Number.isNaN(dateTime)) { - return dateTime - Date.now(); - } - return undefined; + // Header value may be string containing integer seconds + const value = Number(header); + if (!Number.isNaN(value)) { + return value * 1000; + } + + // Or HTTP date time string + const dateTime = Date.parse(header); + if (!Number.isNaN(dateTime)) { + return dateTime - Date.now(); + } + + return undefined; } -function onError(e: AxiosError) { - if (axios.isCancel(e)) { - return Promise.reject(e); - } - - const config = getConfig(e) || {}; - config.currentRetryAttempt = config.currentRetryAttempt || 0; - config.retry = typeof config.retry === 'number' ? config.retry : 3; - config.retryDelay = - typeof config.retryDelay === 'number' ? config.retryDelay : 100; - config.instance = config.instance || axios; - config.backoffType = config.backoffType || 'exponential'; - config.httpMethodsToRetry = normalizeArray(config.httpMethodsToRetry) || [ - 'GET', - 'HEAD', - 'PUT', - 'OPTIONS', - 'DELETE', - ]; - config.noResponseRetries = - typeof config.noResponseRetries === 'number' ? config.noResponseRetries : 2; - config.checkRetryAfter = - typeof config.checkRetryAfter === 'boolean' ? config.checkRetryAfter : true; - config.maxRetryAfter = - typeof config.maxRetryAfter === 'number' ? config.maxRetryAfter : 60000 * 5; - - // If this wasn't in the list of status codes where we want - // to automatically retry, return. - const retryRanges = [ - // https://en.wikipedia.org/wiki/List_of_HTTP_status_codes - // 1xx - Retry (Informational, request still processing) - // 2xx - Do not retry (Success) - // 3xx - Do not retry (Redirect) - // 4xx - Do not retry (Client errors) - // 429 - Retry ("Too Many Requests") - // 5xx - Retry (Server errors) - [100, 199], - [429, 429], - [500, 599], - ]; - config.statusCodesToRetry = - normalizeArray(config.statusCodesToRetry) || retryRanges; - - // Put the config back into the err - const err = e as AxiosError; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (err as any).config = err.config || {}; // allow for wider range of errors - (err.config as RaxConfig).raxConfig = {...config}; - - // Determine if we should retry the request - const shouldRetryFn = config.shouldRetry || shouldRetryRequest; - if (!shouldRetryFn(err)) { - return Promise.reject(err); - } - - // Create a promise that invokes the retry after the backOffDelay - const onBackoffPromise = new Promise((resolve, reject) => { - let delay = 0; - // If enabled, check for 'Retry-After' header in response to use as delay - if ( - config.checkRetryAfter && - err.response && - err.response.headers['retry-after'] - ) { - const retryAfter = parseRetryAfter(err.response.headers['retry-after']); - if (retryAfter && retryAfter > 0 && retryAfter <= config.maxRetryAfter!) { - delay = retryAfter; - } else { - return reject(err); - } - } - - // Now it's certain that a retry is supposed to happen. Incremenent the - // counter, critical for linear and exp backoff delay calc. Note that - // `config.currentRetryAttempt` is local to this function whereas - // `(err.config as RaxConfig).raxConfig` is state that is tranferred across - // retries. That is, we want to mutate `(err.config as - // RaxConfig).raxConfig`. Another important note is about the definition of - // `currentRetryAttempt`: When we are here becasue the first and actual - // HTTP request attempt failed then `currentRetryAttempt` is still zero. We - // have found that a retry is indeed required. Since that is (will be) - // indeed the first retry it makes sense to now increase - // `currentRetryAttempt` by 1. So that it is in fact 1 for the first retry - // (as opposed to 0 or 2); an intuitive convention to use for the math - // below. - (err.config as RaxConfig).raxConfig!.currentRetryAttempt! += 1; - - // store with shorter and more expressive variable name. - const retrycount = (err.config as RaxConfig).raxConfig! - .currentRetryAttempt!; - - // Calculate delay according to chosen strategy - // Default to exponential backoff - formula: ((2^c - 1) / 2) * 1000 - if (delay === 0) { - // was not set by Retry-After logic - if (config.backoffType === 'linear') { - // The delay between the first (actual) attempt and the first retry - // should be non-zero. Rely on the convention that `retrycount` is - // equal to 1 for the first retry when we are in here (was once 0, - // which was a bug -- see #122). - delay = retrycount * 1000; - } else if (config.backoffType === 'static') { - delay = config.retryDelay!; - } else { - delay = ((Math.pow(2, retrycount) - 1) / 2) * 1000; - } - if (typeof config.maxRetryDelay === 'number') { - delay = Math.min(delay, config.maxRetryDelay); - } - } - setTimeout(resolve, delay); - }); - - // Notify the user if they added an `onRetryAttempt` handler - const onRetryAttemptPromise = config.onRetryAttempt - ? Promise.resolve(config.onRetryAttempt(err)) - : Promise.resolve(); - - // Return the promise in which recalls axios to retry the request - return Promise.resolve() - .then(() => onBackoffPromise) - .then(() => onRetryAttemptPromise) - .then(() => config.instance!.request(err.config!)); +async function onError(error: AxiosError) { + if (isCancel(error)) { + throw error; + } + + const config = getConfig(error) || {}; + config.currentRetryAttempt = config.currentRetryAttempt || 0; + config.retry = typeof config.retry === 'number' ? config.retry : 3; + config.retryDelay = + typeof config.retryDelay === 'number' ? config.retryDelay : 100; + config.instance = config.instance || axios; + config.backoffType = config.backoffType || 'exponential'; + config.httpMethodsToRetry = normalizeArray(config.httpMethodsToRetry) || [ + 'GET', + 'HEAD', + 'PUT', + 'OPTIONS', + 'DELETE', + ]; + config.noResponseRetries = + typeof config.noResponseRetries === 'number' ? config.noResponseRetries : 2; + config.checkRetryAfter = + typeof config.checkRetryAfter === 'boolean' ? config.checkRetryAfter : true; + config.maxRetryAfter = + typeof config.maxRetryAfter === 'number' + ? config.maxRetryAfter + : 60_000 * 5; + + // If this wasn't in the list of status codes where we want + // to automatically retry, return. + const retryRanges = [ + // https://en.wikipedia.org/wiki/List_of_HTTP_status_codes + // 1xx - Retry (Informational, request still processing) + // 2xx - Do not retry (Success) + // 3xx - Do not retry (Redirect) + // 4xx - Do not retry (Client errors) + // 429 - Retry ("Too Many Requests") + // 5xx - Retry (Server errors) + [100, 199], + [429, 429], + [500, 599], + ]; + config.statusCodesToRetry = + normalizeArray(config.statusCodesToRetry) || retryRanges; + + // Put the config back into the err + const axiosError = error as AxiosError; + + (axiosError as any).config = axiosError.config || {}; // Allow for wider range of errors + (axiosError.config as RaxConfig).raxConfig = {...config}; + + // Determine if we should retry the request + const shouldRetryFn = config.shouldRetry || shouldRetryRequest; + if (!shouldRetryFn(axiosError)) { + throw axiosError; + } + + // Create a promise that invokes the retry after the backOffDelay + const onBackoffPromise = new Promise((resolve, reject) => { + let delay = 0; + // If enabled, check for 'Retry-After' header in response to use as delay + if (config.checkRetryAfter && axiosError.response?.headers['retry-after']) { + const retryAfter = parseRetryAfter( + axiosError.response.headers['retry-after'] as string, + ); + if (retryAfter && retryAfter > 0 && retryAfter <= config.maxRetryAfter!) { + delay = retryAfter; + } else { + reject(axiosError); + return; + } + } + + // Now it's certain that a retry is supposed to happen. Incremenent the + // counter, critical for linear and exp backoff delay calc. Note that + // `config.currentRetryAttempt` is local to this function whereas + // `(err.config as RaxConfig).raxConfig` is state that is tranferred across + // retries. That is, we want to mutate `(err.config as + // RaxConfig).raxConfig`. Another important note is about the definition of + // `currentRetryAttempt`: When we are here becasue the first and actual + // HTTP request attempt failed then `currentRetryAttempt` is still zero. We + // have found that a retry is indeed required. Since that is (will be) + // indeed the first retry it makes sense to now increase + // `currentRetryAttempt` by 1. So that it is in fact 1 for the first retry + // (as opposed to 0 or 2); an intuitive convention to use for the math + // below. + (axiosError.config as RaxConfig).raxConfig.currentRetryAttempt! += 1; + + // Store with shorter and more expressive variable name. + const retrycount = (axiosError.config as RaxConfig).raxConfig + .currentRetryAttempt!; + + // Calculate delay according to chosen strategy + // Default to exponential backoff - formula: ((2^c - 1) / 2) * 1000 + if (delay === 0) { + // Was not set by Retry-After logic + if (config.backoffType === 'linear') { + // The delay between the first (actual) attempt and the first retry + // should be non-zero. Rely on the convention that `retrycount` is + // equal to 1 for the first retry when we are in here (was once 0, + // which was a bug -- see #122). + delay = retrycount * 1000; + } else if (config.backoffType === 'static') { + delay = config.retryDelay!; + } else { + delay = ((2 ** retrycount - 1) / 2) * 1000; + } + + if (typeof config.maxRetryDelay === 'number') { + delay = Math.min(delay, config.maxRetryDelay); + } + } + + setTimeout(resolve, delay); + }); + + // Notify the user if they added an `onRetryAttempt` handler + if (config.onRetryAttempt) { + config.onRetryAttempt(axiosError); + } + + const onRetryAttemptPromise = Promise.resolve(); + + // Return the promise in which recalls axios to retry the request + return Promise.resolve() + .then(async () => onBackoffPromise) + .then(async () => onRetryAttemptPromise) + .then(async () => config.instance!.request(axiosError.config!)); } /** * Determine based on config if we should retry the request. * @param err The AxiosError passed to the interceptor. */ -export function shouldRetryRequest(err: AxiosError) { - const config = (err.config as RaxConfig).raxConfig; - - // If there's no config, or retries are disabled, return. - if (!config || config.retry === 0) { - return false; - } - - // Check if this error has no response (ETIMEDOUT, ENOTFOUND, etc) - if ( - !err.response && - (config.currentRetryAttempt || 0) >= config.noResponseRetries! - ) { - return false; - } - - // Only retry with configured HttpMethods. - if ( - !err.config?.method || - config.httpMethodsToRetry!.indexOf(err.config.method.toUpperCase()) < 0 - ) { - return false; - } - - // If this wasn't in the list of status codes where we want - // to automatically retry, return. - if (err.response && err.response.status) { - let isInRange = false; - for (const [min, max] of config.statusCodesToRetry!) { - const status = err.response.status; - if (status >= min && status <= max) { - isInRange = true; - break; - } - } - if (!isInRange) { - return false; - } - } - - // If we are out of retry attempts, return - config.currentRetryAttempt = config.currentRetryAttempt || 0; - if (config.currentRetryAttempt >= config.retry!) { - return false; - } - - return true; +export function shouldRetryRequest(error: AxiosError) { + const config = (error.config as RaxConfig).raxConfig; + + // If there's no config, or retries are disabled, return. + if (!config || config.retry === 0) { + return false; + } + + // Check if this error has no response (ETIMEDOUT, ENOTFOUND, etc) + if ( + !error.response && + (config.currentRetryAttempt || 0) >= config.noResponseRetries! + ) { + return false; + } + + // Only retry with configured HttpMethods. + if ( + !error.config?.method || + !config.httpMethodsToRetry!.includes(error.config.method.toUpperCase()) + ) { + return false; + } + + // If this wasn't in the list of status codes where we want + // to automatically retry, return. + if (error.response?.status) { + let isInRange = false; + for (const [min, max] of config.statusCodesToRetry!) { + const {status} = error.response; + if (status >= min && status <= max) { + isInRange = true; + break; + } + } + + if (!isInRange) { + return false; + } + } + + // If we are out of retry attempts, return + config.currentRetryAttempt = config.currentRetryAttempt || 0; + if (config.currentRetryAttempt >= config.retry!) { + return false; + } + + return true; } /** * Acquire the raxConfig object from an AxiosError if available. * @param err The Axios error with a config object. */ -export function getConfig(err: AxiosError) { - if (err && err.config) { - return (err.config as RaxConfig).raxConfig; - } - return; +export function getConfig(error: AxiosError) { + if (error?.config) { + return (error.config as RaxConfig).raxConfig; + } } // Include this so `config.raxConfig` works easily. // See https://github.com/JustinBeckwith/retry-axios/issues/64. declare module 'axios' { - export interface AxiosRequestConfig { - raxConfig?: RetryConfig; - } + export interface AxiosRequestConfig { + raxConfig?: RetryConfig; + } } diff --git a/test/index.ts b/test/index.ts index f7e523a..4798986 100644 --- a/test/index.ts +++ b/test/index.ts @@ -1,640 +1,693 @@ -import assert from 'assert'; -import axios, {AxiosError, AxiosRequestConfig} from 'axios'; +import assert from 'node:assert'; +import process from 'node:process'; +import axios, {type AxiosError, type AxiosRequestConfig} from 'axios'; import nock from 'nock'; -import sinon from 'sinon'; +import * as sinon from 'sinon'; import {describe, it, afterEach} from 'mocha'; import * as rax from '../src/index.js'; -import {RaxConfig} from '../src/index.js'; +import {type RaxConfig} from '../src/index.js'; const url = 'http://test.local'; nock.disableNetConnect(); describe('retry-axios', () => { - let interceptorId: number | undefined; - afterEach(() => { - sinon.restore(); - nock.cleanAll(); - if (interceptorId !== undefined) { - rax.detach(interceptorId); - } - }); - - it('should provide an expected set of defaults', async () => { - const scope = nock(url).get('/').thrice().reply(500); - interceptorId = rax.attach(); - try { - await axios(url); - } catch (ex) { - const e = ex as AxiosError; - scope.done(); - const config = rax.getConfig(e); - assert.strictEqual(config!.currentRetryAttempt, 3, 'currentRetryAttempt'); - assert.strictEqual(config!.retry, 3, 'retry'); - assert.strictEqual(config!.noResponseRetries, 2, 'noResponseRetries'); - assert.strictEqual(config!.retryDelay, 100, 'retryDelay'); - assert.strictEqual(config!.instance, axios, 'axios'); - assert.strictEqual(config!.backoffType, 'exponential', 'backoffType'); - assert.strictEqual(config!.checkRetryAfter, true); - assert.strictEqual(config!.maxRetryAfter, 60000 * 5); - const expectedMethods = ['GET', 'HEAD', 'PUT', 'OPTIONS', 'DELETE']; - for (const method of config!.httpMethodsToRetry!) { - assert(expectedMethods.indexOf(method) > -1, 'exected method: $method'); - } - const expectedStatusCodes = [ - [100, 199], - [429, 429], - [500, 599], - ]; - const statusCodesToRetry = config!.statusCodesToRetry!; - for (let i = 0; i < statusCodesToRetry.length; i++) { - const [min, max] = statusCodesToRetry[i]; - const [expMin, expMax] = expectedStatusCodes[i]; - assert.strictEqual(min, expMin, 'status code min'); - assert.strictEqual(max, expMax, 'status code max'); - } - return; - } - assert.fail('Expected to throw.'); - }); - - it('should retry on 500 on the main export', async () => { - const scopes = [ - nock(url).get('/').reply(500), - nock(url).get('/').reply(200, 'toast'), - ]; - interceptorId = rax.attach(); - const res = await axios({url}); - assert.strictEqual(res.data, 'toast'); - scopes.forEach(s => s.done()); - }); - - it('should not retry on a post', async () => { - const scope = nock(url).post('/').reply(500); - interceptorId = rax.attach(); - try { - await axios.post(url); - } catch (ex) { - const e = ex as AxiosError; - const config = rax.getConfig(e); - assert.strictEqual(config!.currentRetryAttempt, 0); - scope.done(); - return; - } - assert.fail('Expected to throw'); - }); - - it('should retry at least the configured number of times', async function () { - this.timeout(10000); - const scopes = [ - nock(url).get('/').times(3).reply(500), - nock(url).get('/').reply(200, 'milk'), - ]; - interceptorId = rax.attach(); - const cfg: rax.RaxConfig = {url, raxConfig: {retry: 4}}; - const res = await axios(cfg); - assert.strictEqual(res.data, 'milk'); - scopes.forEach(s => s.done()); - }); - - it('should not retry more than configured', async () => { - const scope = nock(url).get('/').twice().reply(500); - interceptorId = rax.attach(); - const cfg: rax.RaxConfig = {url, raxConfig: {retry: 1}}; - try { - await axios(cfg); - } catch (ex) { - const e = ex as AxiosError; - assert.strictEqual(rax.getConfig(e)!.currentRetryAttempt, 1); - scope.done(); - return; - } - assert.fail('Expected to throw'); - }); - - it('should have non-zero delay between first and second attempt, static backoff', async () => { - const requesttimes: bigint[] = []; - const scopes = [ - nock(url) - .get('/') - .reply(() => { - requesttimes.push(process.hrtime.bigint()); - return [500, 'foo']; - }), - nock(url) - .get('/') - .reply(() => { - requesttimes.push(process.hrtime.bigint()); - return [200, 'bar']; - }), - ]; - - interceptorId = rax.attach(); - const res = await axios({ - url, - raxConfig: { - backoffType: 'static', - }, - }); - - // Confirm that first retry did yield 200 OK with expected body - assert.strictEqual(res.data, 'bar'); - scopes.forEach(s => s.done()); - - assert.strictEqual(requesttimes.length, 2); - const delayInSeconds = Number(requesttimes[1] - requesttimes[0]) / 10 ** 9; - - // The default delay between attempts using the - // static backoff strategy is 100 ms. Test with tolerance. - assert.strict( - 0.16 > delayInSeconds && delayInSeconds > 0.1, - `unexpected delay: ${delayInSeconds.toFixed(3)} s` - ); - }); - - it('should have non-zero delay between first and second attempt, linear backoff', async () => { - const requesttimes: bigint[] = []; - const scopes = [ - nock(url) - .get('/') - .reply(() => { - requesttimes.push(process.hrtime.bigint()); - return [500, 'foo']; - }), - nock(url) - .get('/') - .reply(() => { - requesttimes.push(process.hrtime.bigint()); - return [200, 'bar']; - }), - ]; - - interceptorId = rax.attach(); - const res = await axios({ - url, - raxConfig: { - backoffType: 'linear', - }, - }); - - // Confirm that first retry did yield 200 OK with expected body - assert.strictEqual(res.data, 'bar'); - scopes.forEach(s => s.done()); - - assert.strictEqual(requesttimes.length, 2); - const delayInSeconds = Number(requesttimes[1] - requesttimes[0]) / 10 ** 9; - - // The default delay between the first two attempts using the - // linear backoff strategy is 1000 ms. Test with tolerance. - assert.strict( - 1.1 > delayInSeconds && delayInSeconds > 1.0, - `unexpected delay: ${delayInSeconds.toFixed(3)} s` - ); - }); - - it('should have non-zero delay between first and second attempt, exp backoff', async () => { - const requesttimes: bigint[] = []; - const scopes = [ - nock(url) - .get('/') - .reply(() => { - requesttimes.push(process.hrtime.bigint()); - return [500, 'foo']; - }), - nock(url) - .get('/') - .reply(() => { - requesttimes.push(process.hrtime.bigint()); - return [200, 'bar']; - }), - ]; - - interceptorId = rax.attach(); - const res = await axios({ - url, - raxConfig: { - backoffType: 'exponential', - }, - }); - - // Confirm that first retry did yield 200 OK with expected body - assert.strictEqual(res.data, 'bar'); - scopes.forEach(s => s.done()); - - assert.strictEqual(requesttimes.length, 2); - const delayInSeconds = Number(requesttimes[1] - requesttimes[0]) / 10 ** 9; - - // The default delay between attempts using the - // exp backoff strategy is 500 ms. Test with tolerance. - assert.strict( - 0.55 > delayInSeconds && delayInSeconds > 0.5, - `unexpected delay: ${delayInSeconds.toFixed(3)} s` - ); - }); - - it('should accept a new axios instance', async () => { - const scopes = [ - nock(url).get('/').times(2).reply(500), - nock(url).get('/').reply(200, 'raisins'), - ]; - const ax = axios.create(); - interceptorId = rax.attach(ax); - const cfg = {raxConfig: {instance: ax}} as RaxConfig; - const res = await ax.get(url, cfg); - assert.strictEqual(res.data, 'raisins'); - scopes.forEach(s => s.done()); - - // now make sure it fails the first time with just `axios` - const scope = nock(url).get('/').reply(500); - assert.notStrictEqual(ax, axios); - try { - await axios({url}); - } catch (ex) { - const e = ex as AxiosError; - assert.strictEqual(undefined, rax.getConfig(e)); - scope.done(); - return; - } - assert.fail('Expected to throw'); - }); - - it('should accept defaults on a new instance', async () => { - const scopes = [ - nock(url).get('/').times(2).reply(500), - nock(url).get('/').reply(200, '🥧'), - ]; - const ax = axios.create(); - ax.defaults.raxConfig = { - retry: 3, - instance: ax, - onRetryAttempt: evt => { - console.log(`attempt #${evt.config!.raxConfig?.currentRetryAttempt}`); - }, - }; - interceptorId = rax.attach(ax); - const res = await ax.get(url); - assert.strictEqual(res.data, '🥧'); - scopes.forEach(s => s.done()); - }); - - it('should not retry on 4xx errors', async () => { - const scope = nock(url).get('/').reply(404); - interceptorId = rax.attach(); - try { - await axios.get(url); - } catch (ex) { - const e = ex as AxiosError; - const cfg = rax.getConfig(e); - assert.strictEqual(cfg!.currentRetryAttempt, 0); - scope.done(); - return; - } - assert.fail('Expected to throw'); - }); - - it('should not retry if retries set to 0', async () => { - const scope = nock(url).get('/').reply(500); - interceptorId = rax.attach(); - try { - const cfg: rax.RaxConfig = {url, raxConfig: {retry: 0}}; - await axios(cfg); - } catch (ex) { - const e = ex as AxiosError; - const cfg = rax.getConfig(e); - assert.strictEqual(0, cfg!.currentRetryAttempt); - scope.done(); - return; - } - assert.fail('Expected to throw'); - }); - - it('should allow configuring backoffType', async () => { - const scope = nock(url).get('/').replyWithError({code: 'ETIMEDOUT'}); - interceptorId = rax.attach(); - const config: AxiosRequestConfig = { - url, - raxConfig: {backoffType: 'exponential'}, - }; - try { - await axios(config); - } catch (ex) { - const e = ex as AxiosError; - const cfg = rax.getConfig(e); - assert.strictEqual(cfg!.backoffType, 'exponential'); - scope.isDone(); - return; - } - assert.fail('Expected to throw'); - }); - - it('should notify on retry attempts', async () => { - const scopes = [ - nock(url).get('/').reply(500), - nock(url).get('/').reply(200, 'toast'), - ]; - interceptorId = rax.attach(); - let flipped = false; - const config: RaxConfig = { - url, - raxConfig: { - onRetryAttempt: err => { - const cfg = rax.getConfig(err); - assert.strictEqual(cfg!.currentRetryAttempt, 1); - flipped = true; - }, - }, - }; - await axios(config); - assert.strictEqual(flipped, true); - scopes.forEach(s => s.done()); - }); - - it('should notify on retry attempts as a promise', async () => { - const scopes = [ - nock(url).get('/').reply(500), - nock(url).get('/').reply(200, 'toast'), - ]; - interceptorId = rax.attach(); - let flipped = false; - const config: RaxConfig = { - url, - raxConfig: { - onRetryAttempt: err => { - return new Promise(resolve => { - const cfg = rax.getConfig(err); - assert.strictEqual(cfg!.currentRetryAttempt, 1); - flipped = true; - resolve(undefined); - }); - }, - }, - }; - await axios(config); - assert.strictEqual(flipped, true); - scopes.forEach(s => s.done()); - }); - - it('should support overriding the shouldRetry method', async () => { - const scope = nock(url).get('/').reply(500); - interceptorId = rax.attach(); - const config: RaxConfig = { - url, - raxConfig: { - shouldRetry: err => { - rax.getConfig(err); - return false; - }, - }, - }; - try { - await axios(config); - } catch (ex) { - const e = ex as AxiosError; - const cfg = rax.getConfig(e); - assert.strictEqual(cfg!.currentRetryAttempt, 0); - scope.done(); - return; - } - assert.fail('Expected to throw'); - }); - - it('should retry on ENOTFOUND', async () => { - const scopes = [ - nock(url).get('/').replyWithError({code: 'ENOTFOUND'}), - nock(url).get('/').reply(200, 'oatmeal'), - ]; - interceptorId = rax.attach(); - const res = await axios.get(url); - assert.strictEqual(res.data, 'oatmeal'); - scopes.forEach(s => s.done()); - }); - - it('should retry on ETIMEDOUT', async () => { - const scopes = [ - nock(url).get('/').replyWithError({code: 'ETIMEDOUT'}), - nock(url).get('/').reply(200, 'bacon'), - ]; - interceptorId = rax.attach(); - const res = await axios.get(url); - assert.strictEqual(res.data, 'bacon'); - scopes.forEach(s => s.done()); - }); - - it('should allow configuring noResponseRetries', async () => { - const scope = nock(url).get('/').replyWithError({code: 'ETIMEDOUT'}); - interceptorId = rax.attach(); - const config = {url, raxConfig: {noResponseRetries: 0}}; - try { - await axios(config); - } catch (ex) { - const e = ex as AxiosError; - const cfg = rax.getConfig(e); - assert.strictEqual(cfg!.currentRetryAttempt, 0); - scope.isDone(); - return; - } - assert.fail('Expected to throw'); - }); - - it('should reset error counter upon success', async () => { - const scopes = [ - nock(url).get('/').times(2).reply(500), - nock(url).get('/').reply(200, 'milk'), - nock(url).get('/').reply(500), - nock(url).get('/').reply(200, 'toast'), - ]; - interceptorId = rax.attach(); - const cfg: rax.RaxConfig = {url, raxConfig: {retry: 2}}; - const res = await axios(cfg); - assert.strictEqual(res.data, 'milk'); - const res2 = await axios(cfg); - assert.strictEqual(res2.data, 'toast'); - scopes.forEach(s => s.done()); - }); - - it('should ignore requests that have been canceled', async () => { - const scopes = [ - nock(url).get('/').times(2).delay(5).reply(500), - nock(url).get('/').reply(200, 'toast'), - ]; - interceptorId = rax.attach(); - try { - const src = axios.CancelToken.source(); - const cfg: rax.RaxConfig = { - url, - raxConfig: {retry: 2}, - cancelToken: src.token, - }; - const req = axios(cfg); - setTimeout(() => { - src.cancel(); - }, 10); - await req; - throw new Error('The canceled request completed.'); - } catch (err) { - assert.strictEqual(axios.isCancel(err), true); - } - assert.strictEqual(scopes[1].isDone(), false); - }); - - it('should accept 0 for config.retryDelay', async () => { - const scope = nock(url).get('/').replyWithError({code: 'ETIMEDOUT'}); - interceptorId = rax.attach(); - const config: AxiosRequestConfig = { - url, - raxConfig: {retryDelay: 0}, - }; - try { - await axios(config); - } catch (ex) { - const e = ex as AxiosError; - const cfg = rax.getConfig(e); - assert.strictEqual(cfg!.retryDelay, 0); - scope.isDone(); - return; - } - assert.fail('Expected to throw'); - }); - - it('should accept 0 for config.retry', async () => { - const scope = nock(url).get('/').replyWithError({code: 'ETIMEDOUT'}); - interceptorId = rax.attach(); - const config: AxiosRequestConfig = { - url, - raxConfig: {retry: 0}, - }; - try { - await axios(config); - } catch (ex) { - const e = ex as AxiosError; - const cfg = rax.getConfig(e); - assert.strictEqual(cfg!.retry, 0); - scope.isDone(); - return; - } - assert.fail('Expected to throw'); - }); - - it('should accept 0 for config.noResponseRetries', async () => { - const scope = nock(url).get('/').replyWithError({code: 'ETIMEDOUT'}); - interceptorId = rax.attach(); - const config: AxiosRequestConfig = { - url, - raxConfig: {noResponseRetries: 0}, - }; - try { - await axios(config); - } catch (ex) { - const e = ex as AxiosError; - const cfg = rax.getConfig(e); - assert.strictEqual(cfg!.noResponseRetries, 0); - scope.isDone(); - return; - } - assert.fail('Expected to throw'); - }); - - it('should retry with Retry-After header in seconds', async function () { - this.timeout(1000); // Short timeout to trip test if delay longer than expected - const scopes = [ - nock(url).get('/').reply(429, undefined, { - 'Retry-After': '5', - }), - nock(url).get('/').reply(200, 'toast'), - ]; - interceptorId = rax.attach(); - const {promise, resolve} = invertedPromise(); - const clock = sinon.useFakeTimers({ - shouldAdvanceTime: true, // Otherwise interferes with nock - }); - const axiosPromise = axios({ - url, - raxConfig: { - onRetryAttempt: resolve, - retryDelay: 10000, // Higher default to ensure Retry-After is used - backoffType: 'static', - }, - }); - await promise; - clock.tick(5000); // Advance clock by expected retry delay - const res = await axiosPromise; - assert.strictEqual(res.data, 'toast'); - scopes.forEach(s => s.done()); - }); - - it('should retry with Retry-After header in http datetime', async function () { - this.timeout(1000); - const scopes = [ - nock(url).get('/').reply(429, undefined, { - 'Retry-After': 'Thu, 01 Jan 1970 00:00:05 UTC', - }), - nock(url).get('/').reply(200, 'toast'), - ]; - interceptorId = rax.attach(); - const {promise, resolve} = invertedPromise(); - const clock = sinon.useFakeTimers({ - shouldAdvanceTime: true, - }); - const axiosPromise = axios({ - url, - raxConfig: { - onRetryAttempt: resolve, - backoffType: 'static', - retryDelay: 10000, - }, - }); - await promise; - clock.tick(5000); - const res = await axiosPromise; - assert.strictEqual(res.data, 'toast'); - scopes.forEach(s => s.done()); - }); - - it('should not retry if Retry-After greater than maxRetryAfter', async () => { - const scopes = [ - nock(url).get('/').reply(429, undefined, {'Retry-After': '2'}), - nock(url).get('/').reply(200, 'toast'), - ]; - interceptorId = rax.attach(); - const cfg: rax.RaxConfig = {url, raxConfig: {maxRetryAfter: 1000}}; - await assert.rejects(axios(cfg)); - assert.strictEqual(scopes[1].isDone(), false); - }); - - it('should use maxRetryDelay', async function () { - this.timeout(1000); // Short timeout to trip test if delay longer than expected - const scopes = [ - nock(url).get('/').reply(429, undefined), - nock(url).get('/').reply(200, 'toast'), - ]; - interceptorId = rax.attach(); - const {promise, resolve} = invertedPromise(); - const clock = sinon.useFakeTimers({ - shouldAdvanceTime: true, // Otherwise interferes with nock - }); - const axiosPromise = axios({ - url, - raxConfig: { - onRetryAttempt: resolve, - retryDelay: 10000, // Higher default to ensure maxRetryDelay is used - maxRetryDelay: 5000, - backoffType: 'exponential', - }, - }); - await promise; - clock.tick(5000); // Advance clock by expected retry delay - const res = await axiosPromise; - assert.strictEqual(res.data, 'toast'); - scopes.forEach(s => s.done()); - }); + let interceptorId: number | undefined; + afterEach(() => { + sinon.restore(); + nock.cleanAll(); + if (interceptorId !== undefined) { + rax.detach(interceptorId); + } + }); + + it('should provide an expected set of defaults', async () => { + const scope = nock(url).get('/').thrice().reply(500); + interceptorId = rax.attach(); + try { + await axios(url); + } catch (error) { + const axiosError = error as AxiosError; + scope.done(); + const config = rax.getConfig(axiosError); + assert.strictEqual(config!.currentRetryAttempt, 3, 'currentRetryAttempt'); + assert.strictEqual(config!.retry, 3, 'retry'); + assert.strictEqual(config!.noResponseRetries, 2, 'noResponseRetries'); + assert.strictEqual(config!.retryDelay, 100, 'retryDelay'); + assert.strictEqual(config!.instance, axios, 'axios'); + assert.strictEqual(config!.backoffType, 'exponential', 'backoffType'); + assert.strictEqual(config!.checkRetryAfter, true); + assert.strictEqual(config!.maxRetryAfter, 60_000 * 5); + const expectedMethods = new Set([ + 'GET', + 'HEAD', + 'PUT', + 'OPTIONS', + 'DELETE', + ]); + for (const method of config!.httpMethodsToRetry!) { + assert(expectedMethods.has(method), 'exected method: $method'); + } + + const expectedStatusCodes = [ + [100, 199], + [429, 429], + [500, 599], + ]; + const statusCodesToRetry = config!.statusCodesToRetry!; + for (const [i, [min, max]] of statusCodesToRetry.entries()) { + const [expMin, expMax] = expectedStatusCodes[i]; + assert.strictEqual(min, expMin, 'status code min'); + assert.strictEqual(max, expMax, 'status code max'); + } + + return; + } + + assert.fail('Expected to throw.'); + }); + + it('should retry on 500 on the main export', async () => { + const scopes = [ + nock(url).get('/').reply(500), + nock(url).get('/').reply(200, 'toast'), + ]; + interceptorId = rax.attach(); + const result = await axios({url}); + assert.strictEqual(result.data, 'toast'); + for (const s of scopes) { + s.done(); + } + }); + + it('should not retry on a post', async () => { + const scope = nock(url).post('/').reply(500); + interceptorId = rax.attach(); + try { + await axios.post(url); + } catch (error) { + const axiosError = error as AxiosError; + const config = rax.getConfig(axiosError); + assert.strictEqual(config!.currentRetryAttempt, 0); + scope.done(); + return; + } + + assert.fail('Expected to throw'); + }); + + it('should retry at least the configured number of times', async function () { + this.timeout(10_000); + const scopes = [ + nock(url).get('/').times(3).reply(500), + nock(url).get('/').reply(200, 'milk'), + ]; + interceptorId = rax.attach(); + const cfg: rax.RaxConfig = {url, raxConfig: {retry: 4}}; + const result = await axios(cfg); + assert.strictEqual(result.data, 'milk'); + for (const s of scopes) { + s.done(); + } + }); + + it('should not retry more than configured', async () => { + const scope = nock(url).get('/').twice().reply(500); + interceptorId = rax.attach(); + const cfg: rax.RaxConfig = {url, raxConfig: {retry: 1}}; + try { + await axios(cfg); + } catch (error) { + const axiosError = error as AxiosError; + assert.strictEqual(rax.getConfig(axiosError)!.currentRetryAttempt, 1); + scope.done(); + return; + } + + assert.fail('Expected to throw'); + }); + + it('should have non-zero delay between first and second attempt, static backoff', async () => { + const requesttimes: bigint[] = []; + const scopes = [ + nock(url) + .get('/') + .reply(() => { + requesttimes.push(process.hrtime.bigint()); + return [500, 'foo']; + }), + nock(url) + .get('/') + .reply(() => { + requesttimes.push(process.hrtime.bigint()); + return [200, 'bar']; + }), + ]; + + interceptorId = rax.attach(); + const result = await axios({ + url, + raxConfig: { + backoffType: 'static', + }, + }); + + // Confirm that first retry did yield 200 OK with expected body + assert.strictEqual(result.data, 'bar'); + for (const s of scopes) { + s.done(); + } + + assert.strictEqual(requesttimes.length, 2); + const delayInSeconds = Number(requesttimes[1] - requesttimes[0]) / 10 ** 9; + + // The default delay between attempts using the + // static backoff strategy is 100 ms. Test with tolerance. + assert.strict( + delayInSeconds < 0.16 && delayInSeconds > 0.1, + `unexpected delay: ${delayInSeconds.toFixed(3)} s`, + ); + }); + + it('should have non-zero delay between first and second attempt, linear backoff', async () => { + const requesttimes: bigint[] = []; + const scopes = [ + nock(url) + .get('/') + .reply(() => { + requesttimes.push(process.hrtime.bigint()); + return [500, 'foo']; + }), + nock(url) + .get('/') + .reply(() => { + requesttimes.push(process.hrtime.bigint()); + return [200, 'bar']; + }), + ]; + + interceptorId = rax.attach(); + const result = await axios({ + url, + raxConfig: { + backoffType: 'linear', + }, + }); + + // Confirm that first retry did yield 200 OK with expected body + assert.strictEqual(result.data, 'bar'); + for (const s of scopes) { + s.done(); + } + + assert.strictEqual(requesttimes.length, 2); + const delayInSeconds = Number(requesttimes[1] - requesttimes[0]) / 10 ** 9; + + // The default delay between the first two attempts using the + // linear backoff strategy is 1000 ms. Test with tolerance. + assert.strict( + delayInSeconds < 1.1 && delayInSeconds > 1, + `unexpected delay: ${delayInSeconds.toFixed(3)} s`, + ); + }); + + it('should have non-zero delay between first and second attempt, exp backoff', async () => { + const requesttimes: bigint[] = []; + const scopes = [ + nock(url) + .get('/') + .reply(() => { + requesttimes.push(process.hrtime.bigint()); + return [500, 'foo']; + }), + nock(url) + .get('/') + .reply(() => { + requesttimes.push(process.hrtime.bigint()); + return [200, 'bar']; + }), + ]; + + interceptorId = rax.attach(); + const result = await axios({ + url, + raxConfig: { + backoffType: 'exponential', + }, + }); + + // Confirm that first retry did yield 200 OK with expected body + assert.strictEqual(result.data, 'bar'); + for (const s of scopes) { + s.done(); + } + + assert.strictEqual(requesttimes.length, 2); + const delayInSeconds = Number(requesttimes[1] - requesttimes[0]) / 10 ** 9; + + // The default delay between attempts using the + // exp backoff strategy is 500 ms. Test with tolerance. + assert.strict( + delayInSeconds < 0.55 && delayInSeconds > 0.5, + `unexpected delay: ${delayInSeconds.toFixed(3)} s`, + ); + }); + + it('should accept a new axios instance', async () => { + const scopes = [ + nock(url).get('/').times(2).reply(500), + nock(url).get('/').reply(200, 'raisins'), + ]; + const ax = axios.create(); + interceptorId = rax.attach(ax); + const cfg: RaxConfig = {raxConfig: {instance: ax}}; + const result = await ax.get(url, cfg); + assert.strictEqual(result.data, 'raisins'); + for (const s of scopes) { + s.done(); + } + + // Now make sure it fails the first time with just `axios` + const scope = nock(url).get('/').reply(500); + assert.notStrictEqual(ax, axios); + try { + await axios({url}); + } catch (error) { + const axiosError = error as AxiosError; + assert.strictEqual(undefined, rax.getConfig(axiosError)); + scope.done(); + return; + } + + assert.fail('Expected to throw'); + }); + + it('should accept defaults on a new instance', async () => { + const scopes = [ + nock(url).get('/').times(2).reply(500), + nock(url).get('/').reply(200, '🥧'), + ]; + const ax = axios.create(); + ax.defaults.raxConfig = { + retry: 3, + instance: ax, + onRetryAttempt(evt) { + console.log(`attempt #${evt.config!.raxConfig?.currentRetryAttempt}`); + }, + }; + interceptorId = rax.attach(ax); + const result = await ax.get(url); + assert.strictEqual(result.data, '🥧'); + for (const s of scopes) { + s.done(); + } + }); + + it('should not retry on 4xx errors', async () => { + const scope = nock(url).get('/').reply(404); + interceptorId = rax.attach(); + try { + await axios.get(url); + } catch (error) { + const axiosError = error as AxiosError; + const cfg = rax.getConfig(axiosError); + assert.strictEqual(cfg!.currentRetryAttempt, 0); + scope.done(); + return; + } + + assert.fail('Expected to throw'); + }); + + it('should not retry if retries set to 0', async () => { + const scope = nock(url).get('/').reply(500); + interceptorId = rax.attach(); + try { + const cfg: rax.RaxConfig = {url, raxConfig: {retry: 0}}; + await axios(cfg); + } catch (error) { + const axiosError = error as AxiosError; + const cfg = rax.getConfig(axiosError); + assert.strictEqual(0, cfg!.currentRetryAttempt); + scope.done(); + return; + } + + assert.fail('Expected to throw'); + }); + + it('should allow configuring backoffType', async () => { + const scope = nock(url).get('/').replyWithError({code: 'ETIMEDOUT'}); + interceptorId = rax.attach(); + const config: AxiosRequestConfig = { + url, + raxConfig: {backoffType: 'exponential'}, + }; + try { + await axios(config); + } catch (error) { + const axiosError = error as AxiosError; + const cfg = rax.getConfig(axiosError); + assert.strictEqual(cfg!.backoffType, 'exponential'); + scope.isDone(); + return; + } + + assert.fail('Expected to throw'); + }); + + it('should notify on retry attempts', async () => { + const scopes = [ + nock(url).get('/').reply(500), + nock(url).get('/').reply(200, 'toast'), + ]; + interceptorId = rax.attach(); + let flipped = false; + const config: RaxConfig = { + url, + raxConfig: { + onRetryAttempt(error) { + const cfg = rax.getConfig(error); + assert.strictEqual(cfg!.currentRetryAttempt, 1); + flipped = true; + }, + }, + }; + await axios(config); + assert.strictEqual(flipped, true); + for (const s of scopes) { + s.done(); + } + }); + + it('should notify on retry attempts as a promise', async () => { + const scopes = [ + nock(url).get('/').reply(500), + nock(url).get('/').reply(200, 'toast'), + ]; + interceptorId = rax.attach(); + let flipped = false; + const config: RaxConfig = { + url, + raxConfig: { + async onRetryAttempt(error) { + return new Promise((resolve) => { + const cfg = rax.getConfig(error); + assert.strictEqual(cfg!.currentRetryAttempt, 1); + flipped = true; + resolve(undefined); + }); + }, + }, + }; + await axios(config); + assert.strictEqual(flipped, true); + for (const s of scopes) { + s.done(); + } + }); + + it('should support overriding the shouldRetry method', async () => { + const scope = nock(url).get('/').reply(500); + interceptorId = rax.attach(); + const config: RaxConfig = { + url, + raxConfig: { + shouldRetry(error) { + rax.getConfig(error); + return false; + }, + }, + }; + try { + await axios(config); + } catch (error) { + const axiosError = error as AxiosError; + const cfg = rax.getConfig(axiosError); + assert.strictEqual(cfg!.currentRetryAttempt, 0); + scope.done(); + return; + } + + assert.fail('Expected to throw'); + }); + + it('should retry on ENOTFOUND', async () => { + const scopes = [ + nock(url).get('/').replyWithError({code: 'ENOTFOUND'}), + nock(url).get('/').reply(200, 'oatmeal'), + ]; + interceptorId = rax.attach(); + const result = await axios.get(url); + assert.strictEqual(result.data, 'oatmeal'); + for (const s of scopes) { + s.done(); + } + }); + + it('should retry on ETIMEDOUT', async () => { + const scopes = [ + nock(url).get('/').replyWithError({code: 'ETIMEDOUT'}), + nock(url).get('/').reply(200, 'bacon'), + ]; + interceptorId = rax.attach(); + const result = await axios.get(url); + assert.strictEqual(result.data, 'bacon'); + for (const s of scopes) { + s.done(); + } + }); + + it('should allow configuring noResponseRetries', async () => { + const scope = nock(url).get('/').replyWithError({code: 'ETIMEDOUT'}); + interceptorId = rax.attach(); + const config = {url, raxConfig: {noResponseRetries: 0}}; + try { + await axios(config); + } catch (error) { + const axiosError = error as AxiosError; + const cfg = rax.getConfig(axiosError); + assert.strictEqual(cfg!.currentRetryAttempt, 0); + scope.isDone(); + return; + } + + assert.fail('Expected to throw'); + }); + + it('should reset error counter upon success', async () => { + const scopes = [ + nock(url).get('/').times(2).reply(500), + nock(url).get('/').reply(200, 'milk'), + nock(url).get('/').reply(500), + nock(url).get('/').reply(200, 'toast'), + ]; + interceptorId = rax.attach(); + const cfg: rax.RaxConfig = {url, raxConfig: {retry: 2}}; + const result = await axios(cfg); + assert.strictEqual(result.data, 'milk'); + const result2 = await axios(cfg); + assert.strictEqual(result2.data, 'toast'); + for (const s of scopes) { + s.done(); + } + }); + + it('should ignore requests that have been canceled', async () => { + const scopes = [ + nock(url).get('/').times(2).delay(5).reply(500), + nock(url).get('/').reply(200, 'toast'), + ]; + interceptorId = rax.attach(); + try { + // eslint-disable-next-line import/no-named-as-default-member + const src = axios.CancelToken.source(); + const cfg: rax.RaxConfig = { + url, + raxConfig: {retry: 2}, + cancelToken: src.token, + }; + const request = axios(cfg); + setTimeout(() => { + src.cancel(); + }, 10); + await request; + throw new Error('The canceled request completed.'); + } catch (error) { + // eslint-disable-next-line import/no-named-as-default-member + assert.strictEqual(axios.isCancel(error), true); + } + + assert.strictEqual(scopes[1].isDone(), false); + }); + + it('should accept 0 for config.retryDelay', async () => { + const scope = nock(url).get('/').replyWithError({code: 'ETIMEDOUT'}); + interceptorId = rax.attach(); + const config: AxiosRequestConfig = { + url, + raxConfig: {retryDelay: 0}, + }; + try { + await axios(config); + } catch (error) { + const axiosError = error as AxiosError; + const cfg = rax.getConfig(axiosError); + assert.strictEqual(cfg!.retryDelay, 0); + scope.isDone(); + return; + } + + assert.fail('Expected to throw'); + }); + + it('should accept 0 for config.retry', async () => { + const scope = nock(url).get('/').replyWithError({code: 'ETIMEDOUT'}); + interceptorId = rax.attach(); + const config: AxiosRequestConfig = { + url, + raxConfig: {retry: 0}, + }; + try { + await axios(config); + } catch (error) { + const axiosError = error as AxiosError; + const cfg = rax.getConfig(axiosError); + assert.strictEqual(cfg!.retry, 0); + scope.isDone(); + return; + } + + assert.fail('Expected to throw'); + }); + + it('should accept 0 for config.noResponseRetries', async () => { + const scope = nock(url).get('/').replyWithError({code: 'ETIMEDOUT'}); + interceptorId = rax.attach(); + const config: AxiosRequestConfig = { + url, + raxConfig: {noResponseRetries: 0}, + }; + try { + await axios(config); + } catch (error) { + const axiosError = error as AxiosError; + const cfg = rax.getConfig(axiosError); + assert.strictEqual(cfg!.noResponseRetries, 0); + scope.isDone(); + return; + } + + assert.fail('Expected to throw'); + }); + + it('should retry with Retry-After header in seconds', async function () { + this.timeout(1000); // Short timeout to trip test if delay longer than expected + const scopes = [ + nock(url).get('/').reply(429, undefined, { + 'Retry-After': '5', + }), + nock(url).get('/').reply(200, 'toast'), + ]; + interceptorId = rax.attach(); + const {promise, resolve} = invertedPromise(); + const clock = sinon.useFakeTimers({ + shouldAdvanceTime: true, // Otherwise interferes with nock + }); + const axiosPromise = axios({ + url, + raxConfig: { + onRetryAttempt: resolve, + retryDelay: 10_000, // Higher default to ensure Retry-After is used + backoffType: 'static', + }, + }); + await promise; + clock.tick(5000); // Advance clock by expected retry delay + const result = await axiosPromise; + assert.strictEqual(result.data, 'toast'); + for (const s of scopes) { + s.done(); + } + }); + + it('should retry with Retry-After header in http datetime', async function () { + this.timeout(1000); + const scopes = [ + nock(url).get('/').reply(429, undefined, { + 'Retry-After': 'Thu, 01 Jan 1970 00:00:05 UTC', + }), + nock(url).get('/').reply(200, 'toast'), + ]; + interceptorId = rax.attach(); + const {promise, resolve} = invertedPromise(); + const clock = sinon.useFakeTimers({ + shouldAdvanceTime: true, + }); + const axiosPromise = axios({ + url, + raxConfig: { + onRetryAttempt: resolve, + backoffType: 'static', + retryDelay: 10_000, + }, + }); + await promise; + clock.tick(5000); + const result = await axiosPromise; + assert.strictEqual(result.data, 'toast'); + for (const s of scopes) { + s.done(); + } + }); + + it('should not retry if Retry-After greater than maxRetryAfter', async () => { + const scopes = [ + nock(url).get('/').reply(429, undefined, {'Retry-After': '2'}), + nock(url).get('/').reply(200, 'toast'), + ]; + interceptorId = rax.attach(); + const cfg: rax.RaxConfig = {url, raxConfig: {maxRetryAfter: 1000}}; + await assert.rejects(axios(cfg)); + assert.strictEqual(scopes[1].isDone(), false); + }); + + it('should use maxRetryDelay', async function () { + this.timeout(1000); // Short timeout to trip test if delay longer than expected + const scopes = [ + nock(url).get('/').reply(429, undefined), + nock(url).get('/').reply(200, 'toast'), + ]; + interceptorId = rax.attach(); + const {promise, resolve} = invertedPromise(); + const clock = sinon.useFakeTimers({ + shouldAdvanceTime: true, // Otherwise interferes with nock + }); + const axiosPromise = axios({ + url, + raxConfig: { + onRetryAttempt: resolve, + retryDelay: 10_000, // Higher default to ensure maxRetryDelay is used + maxRetryDelay: 5000, + backoffType: 'exponential', + }, + }); + await promise; + clock.tick(5000); // Advance clock by expected retry delay + const result = await axiosPromise; + assert.strictEqual(result.data, 'toast'); + for (const s of scopes) { + s.done(); + } + }); }); function invertedPromise() { - let resolve!: () => void; - let reject!: (err: Error) => void; - const promise = new Promise((innerResolve, innerReject) => { - resolve = innerResolve; - reject = innerReject; - }); - return {promise, resolve, reject}; + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return {promise, resolve, reject}; } diff --git a/tsconfig.json b/tsconfig.json index 347ac39..55ebbee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { - "extends": "./node_modules/gts/tsconfig-google.json", "compilerOptions": { + "strict": true, + "target": "ES2022", "rootDir": ".", "outDir": "build", "moduleResolution": "node",