From f25b5207f126ce05f6786b2ef77627b493ba1a7d Mon Sep 17 00:00:00 2001 From: Wai Lun Lim Date: Wed, 15 May 2024 00:30:50 +0800 Subject: [PATCH 1/4] Add validateResponse --- src/index.ts | 69 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/src/index.ts b/src/index.ts index 898ed07..89139a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,10 @@ -import type { AxiosError, AxiosRequestConfig, AxiosInstance, AxiosStatic } from 'axios'; +import type { + AxiosError, + AxiosRequestConfig, + AxiosInstance, + AxiosStatic, + AxiosResponse +} from 'axios'; import isRetryAllowed from 'is-retry-allowed'; export interface IAxiosRetryConfig { @@ -34,6 +40,10 @@ export interface IAxiosRetryConfig { * before throwing the error. */ onMaxRetryTimesExceeded?: (error: AxiosError, retryCount: number) => Promise | void; + /** + * Defines whether a response should be resolved or rejected + */ + validateResponse?: ((response: AxiosResponse) => boolean) | null; } export interface IAxiosRetryConfigExtended extends IAxiosRetryConfig { @@ -147,7 +157,8 @@ export const DEFAULT_OPTIONS: Required = { retryDelay: noDelay, shouldResetTimeout: false, onRetry: () => {}, - onMaxRetryTimesExceeded: () => {} + onMaxRetryTimesExceeded: () => {}, + validateResponse: null }; function getRequestOptions( @@ -201,6 +212,32 @@ async function shouldRetry( } return shouldRetryOrPromise; } +async function handleRetry( + axiosInstance: AxiosInstance, + currentState: Required, + error: AxiosError, + config: AxiosRequestConfig +) { + currentState.retryCount += 1; + const { retryDelay, shouldResetTimeout, onRetry } = currentState; + const delay = retryDelay(currentState.retryCount, error); + // Axios fails merging this configuration to the default configuration because it has an issue + // with circular structures: https://github.com/mzabriskie/axios/issues/370 + fixConfig(axiosInstance, config); + if (!shouldResetTimeout && config.timeout && currentState.lastRequestTime) { + const lastRequestDuration = Date.now() - currentState.lastRequestTime; + const timeout = config.timeout - lastRequestDuration - delay; + if (timeout <= 0) { + return Promise.reject(error); + } + config.timeout = timeout; + } + config.transformRequest = [(data) => data]; + await onRetry(currentState.retryCount, error, config); + return new Promise((resolve) => { + setTimeout(() => resolve(axiosInstance(config)), delay); + }); +} async function handleMaxRetryTimesExceeded( currentState: Required, @@ -213,6 +250,10 @@ async function handleMaxRetryTimesExceeded( const axiosRetry: AxiosRetry = (axiosInstance, defaultOptions) => { const requestInterceptorId = axiosInstance.interceptors.request.use((config) => { setCurrentState(config, defaultOptions); + if (config[namespace]?.validateResponse) { + // by setting this, all HTTP responses will be go through the error interceptor first + config.validateStatus = () => false; + } return config; }); @@ -223,26 +264,12 @@ const axiosRetry: AxiosRetry = (axiosInstance, defaultOptions) => { return Promise.reject(error); } const currentState = setCurrentState(config, defaultOptions); + if (error.response && currentState.validateResponse?.(error.response)) { + // no issue with response + return error.response; + } if (await shouldRetry(currentState, error)) { - currentState.retryCount += 1; - const { retryDelay, shouldResetTimeout, onRetry } = currentState; - const delay = retryDelay(currentState.retryCount, error); - // Axios fails merging this configuration to the default configuration because it has an issue - // with circular structures: https://github.com/mzabriskie/axios/issues/370 - fixConfig(axiosInstance, config); - if (!shouldResetTimeout && config.timeout && currentState.lastRequestTime) { - const lastRequestDuration = Date.now() - currentState.lastRequestTime; - const timeout = config.timeout - lastRequestDuration - delay; - if (timeout <= 0) { - return Promise.reject(error); - } - config.timeout = timeout; - } - config.transformRequest = [(data) => data]; - await onRetry(currentState.retryCount, error, config); - return new Promise((resolve) => { - setTimeout(() => resolve(axiosInstance(config)), delay); - }); + return handleRetry(axiosInstance, currentState, error, config); } await handleMaxRetryTimesExceeded(currentState, error); From e92f7dc43583568f2c14b153294ad92d503aef41 Mon Sep 17 00:00:00 2001 From: Wai Lun Lim Date: Wed, 15 May 2024 00:31:09 +0800 Subject: [PATCH 2/4] Add test cases for validateResponse --- spec/index.spec.ts | 111 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/spec/index.spec.ts b/spec/index.spec.ts index 89f9369..21eb08d 100644 --- a/spec/index.spec.ts +++ b/spec/index.spec.ts @@ -1,6 +1,6 @@ import http from 'http'; import nock from 'nock'; -import axios, { AxiosError } from 'axios'; +import axios, { AxiosError, isAxiosError } from 'axios'; import axiosRetry, { isNetworkError, isSafeRequestError, @@ -394,6 +394,115 @@ describe('axiosRetry(axios, { retries, retryCondition })', () => { }); }); +describe('axiosRetry(axios, { validateResponse })', () => { + afterEach(() => { + nock.cleanAll(); + }); + + describe('when validateResponse is supplied as default option', () => { + it('should be able to produce an AxiosError with status code of 200', (done) => { + const client = axios.create(); + setupResponses(client, [ + () => nock('http://example.com').get('/test').reply(200, 'should retry!') + ]); + axiosRetry(client, { + retries: 0, + validateResponse: (response) => response.status !== 200 + }); + client.get('http://example.com/test').catch((err) => { + expect(isAxiosError(err)).toBeTrue(); + expect(err.response.status).toBe(200); + done(); + }); + }); + + it('should retry based on supplied logic', (done) => { + const client = axios.create(); + setupResponses(client, [ + () => nock('http://example.com').get('/test').reply(200, 'should retry!'), + () => nock('http://example.com').get('/test').replyWithError(NETWORK_ERROR), + () => nock('http://example.com').get('/test').reply(200, 'should retry!'), + () => nock('http://example.com').get('/test').reply(200, 'ok!') + ]); + let retryCount = 0; + axiosRetry(client, { + retries: 4, + retryCondition: () => true, + retryDelay: () => { + retryCount += 1; + return 0; + }, + validateResponse: (response) => { + if (response.status < 200 || response.status >= 300) return false; + return response.data === 'ok!'; + } + }); + client.get('http://example.com/test').then((result) => { + expect(retryCount).toBe(3); + expect(result.status).toBe(200); + expect(result.data).toBe('ok!'); + done(); + }, done.fail); + }); + }); + + describe('when validateResponse is supplied as request-specific configuration', () => { + it('should use request-specific configuration instead', (done) => { + const client = axios.create(); + setupResponses(client, [ + () => nock('http://example.com').get('/test').reply(200, 'should retry!'), + () => nock('http://example.com').get('/test').replyWithError(NETWORK_ERROR), + () => nock('http://example.com').get('/test').reply(200, 'ok!') + ]); + axiosRetry(client, { + validateResponse: (response) => response.status >= 200 && response.status < 300 + }); + client + .get('http://example.com/test', { + 'axios-retry': { + retryCondition: () => true, + validateResponse: (response) => { + if (response.status < 200 || response.status >= 300) return false; + return response.data === 'ok!'; + } + } + }) + .then((result) => { + expect(result.status).toBe(200); + expect(result.data).toBe('ok!'); + done(); + }, done.fail); + }); + + it('should be able to disable default validateResponse passed', (done) => { + const client = axios.create(); + setupResponses(client, [ + () => nock('http://example.com').get('/test').reply(200, 'should not retry!'), + () => nock('http://example.com').get('/test').replyWithError(NETWORK_ERROR), + () => nock('http://example.com').get('/test').reply(200, 'ok!') + ]); + axiosRetry(client, { + validateResponse: (response) => { + if (response.status < 200 || response.status >= 300) return false; + return response.data === 'ok!'; + } + }); + client + .get('http://example.com/test', { + 'axios-retry': { + retryCondition: () => true, + validateResponse: null + } + }) + .then((result) => { + expect(result.status).toBe(200); + expect(result.data).toBe('should not retry!'); + done(); + }, done.fail); + }); + }); +}); + describe('axiosRetry(axios, { retries, retryDelay })', () => { describe('when custom retryDelay function is supplied', () => { it('should execute for each retry', (done) => { From 80a05a8566a814e25c62f7ecaa6a2a73489a149a Mon Sep 17 00:00:00 2001 From: Wai Lun Lim Date: Wed, 15 May 2024 00:47:47 +0800 Subject: [PATCH 3/4] Update description; update readme --- README.md | 1 + src/index.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5bf3076..001275a 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ client | retryDelay | `Function` | `function noDelay() { return 0; }` | A callback to further control the delay in milliseconds between retried requests. By default there is no delay between retries. Another option is exponentialDelay ([Exponential Backoff](https://developers.google.com/analytics/devguides/reporting/core/v3/errors#backoff)). The function is passed `retryCount` and `error`. | | onRetry | `Function` | `function onRetry(retryCount, error, requestConfig) { return; }` | A callback to notify when a retry is about to occur. Useful for tracing and you can any async process for example refresh a token on 401. By default nothing will occur. The function is passed `retryCount`, `error`, and `requestConfig`. | | onMaxRetryTimesExceeded | `Function` | `function onMaxRetryTimesExceeded(error, retryCount) { return; }` | After all the retries are failed, this callback will be called with the last error before throwing the error. | +| validateResponse | `Function | null` | `null` | A callback to define whether a response should be resolved or rejected. If null is passed, it will fallback to the axios default (only 2xx status codes are resolved). | ## Testing diff --git a/src/index.ts b/src/index.ts index 89139a6..5b72857 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,7 +41,8 @@ export interface IAxiosRetryConfig { */ onMaxRetryTimesExceeded?: (error: AxiosError, retryCount: number) => Promise | void; /** - * Defines whether a response should be resolved or rejected + * A callback to define whether a response should be resolved or rejected. If null is passed, it will fallback to + * the axios default (only 2xx status codes are resolved). */ validateResponse?: ((response: AxiosResponse) => boolean) | null; } From e9e7d6ff431e5769a1037f067bdc36d2040719a9 Mon Sep 17 00:00:00 2001 From: Wai Lun Lim Date: Wed, 15 May 2024 00:50:09 +0800 Subject: [PATCH 4/4] Fix table error --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 001275a..54d263c 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ client | retryDelay | `Function` | `function noDelay() { return 0; }` | A callback to further control the delay in milliseconds between retried requests. By default there is no delay between retries. Another option is exponentialDelay ([Exponential Backoff](https://developers.google.com/analytics/devguides/reporting/core/v3/errors#backoff)). The function is passed `retryCount` and `error`. | | onRetry | `Function` | `function onRetry(retryCount, error, requestConfig) { return; }` | A callback to notify when a retry is about to occur. Useful for tracing and you can any async process for example refresh a token on 401. By default nothing will occur. The function is passed `retryCount`, `error`, and `requestConfig`. | | onMaxRetryTimesExceeded | `Function` | `function onMaxRetryTimesExceeded(error, retryCount) { return; }` | After all the retries are failed, this callback will be called with the last error before throwing the error. | -| validateResponse | `Function | null` | `null` | A callback to define whether a response should be resolved or rejected. If null is passed, it will fallback to the axios default (only 2xx status codes are resolved). | +| validateResponse | `Function \| null` | `null` | A callback to define whether a response should be resolved or rejected. If null is passed, it will fallback to the axios default (only 2xx status codes are resolved). | ## Testing