Skip to content

Commit

Permalink
Merge pull request #272 from obscurecat64/feat/retry-on-200-response
Browse files Browse the repository at this point in the history
Support retry on 200 response
  • Loading branch information
mindhells authored May 20, 2024
2 parents dbc365f + e9e7d6f commit c99880a
Show file tree
Hide file tree
Showing 3 changed files with 160 additions and 22 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
111 changes: 110 additions & 1 deletion spec/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -395,6 +395,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) => {
Expand Down
70 changes: 49 additions & 21 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -34,6 +40,11 @@ export interface IAxiosRetryConfig {
* before throwing the error.
*/
onMaxRetryTimesExceeded?: (error: AxiosError, retryCount: number) => Promise<void> | void;
/**
* 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;
}

export interface IAxiosRetryConfigExtended extends IAxiosRetryConfig {
Expand Down Expand Up @@ -164,7 +175,8 @@ export const DEFAULT_OPTIONS: Required<IAxiosRetryConfig> = {
retryDelay: noDelay,
shouldResetTimeout: false,
onRetry: () => {},
onMaxRetryTimesExceeded: () => {}
onMaxRetryTimesExceeded: () => {},
validateResponse: null
};

function getRequestOptions(
Expand Down Expand Up @@ -218,6 +230,32 @@ async function shouldRetry(
}
return shouldRetryOrPromise;
}
async function handleRetry(
axiosInstance: AxiosInstance,
currentState: Required<IAxiosRetryConfigExtended>,
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<IAxiosRetryConfigExtended>,
Expand All @@ -230,6 +268,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;
});

Expand All @@ -240,26 +282,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);
Expand Down

0 comments on commit c99880a

Please sign in to comment.