Skip to content

Instantly share code, notes, and snippets.

@mjstrasser
Last active March 1, 2020 21:05
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mjstrasser/69d0d03f0eedb513ae529a0c137800f1 to your computer and use it in GitHub Desktop.
Save mjstrasser/69d0d03f0eedb513ae529a0c137800f1 to your computer and use it in GitHub Desktop.
{
"name": "retry-axios",
"version": "1.0.0",
"description": "Retry function for axios promise-based HTTP client",
"scripts": {
"test": "jest --coverage"
},
"dependencies": {
"is-retry-allowed": "^1.1.0"
},
"devDependencies": {
"babel-cli": "^6.26.0",
"babel-preset-env": "^1.6.1",
"eslint": "^4.19.0",
"eslint-config-airbnb-base": "^12.1.0",
"eslint-plugin-import": "^2.9.0",
"jest": "^22.4.2"
}
}
import isRetryAllowed from 'is-retry-allowed';
/**
* Determines if the error is a network error. Thanks to https://github.com/softonic/axios-retry.
*/
const isNetworkError = error => (
!error.response
&& error.code
&& Boolean(error.code)
&& error.code !== 'ECONNABORTED'
&& isRetryAllowed(error)
);
const isServerError = error => (
error.response
&& error.response.status >= 500
&& error.response.status <= 599
);
const isRetriable = error => isNetworkError(error) || isServerError(error);
const sleep = delay => new Promise(resolve => setTimeout(resolve, delay));
/**
* Retry axios calls if the service fails with a transient error.
*
* @param delays an ES6 iterable (e.g. array)
* @param axiosFunc an axios function to call
* @param axiosArgs arguments to the axios function
*/
export const retryAxios = async (delays, axiosFunc, ...axiosArgs) => { // eslint-disable-line consistent-return
const iterator = delays[Symbol.iterator]();
while (true) { // eslint-disable-line no-constant-condition
try {
return await axiosFunc(...axiosArgs); // eslint-disable-line no-await-in-loop
} catch (error) {
const { done, value } = iterator.next();
if (!done && isRetriable(error)) {
await sleep(value); // eslint-disable-line no-await-in-loop
} else {
throw error;
}
}
}
};
/**
* Iterable that generates sequences of integers, increasing exponentially.
*/
export class ExponentialDelays {
/**
* Constructs an iterable from a starting value and number to return.
*
* @param initialDelay value of first delay to return
* @param retryCount number of values to return
*/
constructor(initialDelay, retryCount) {
this.initialDelay = Math.max(1, initialDelay);
this.retryCount = Math.max(0, retryCount);
}
/**
* Generator that returns values as constructed.
*/
* iterator() {
let delay = this.initialDelay;
for (let retry = 0; retry < this.retryCount; retry += 1) {
yield delay;
delay *= 2;
}
}
/**
* Implements the ES6 iterable protocol by returning the iterator.
*/
[Symbol.iterator]() {
return this.iterator();
}
}
import { retryAxios, ExponentialDelays } from './retryAxios';
// Basic HTTP error class.
class HttpError extends Error {
constructor(response, ...params) {
super(...params);
this.response = response;
}
}
// Basic network error class.
class NetError extends Error {
constructor(code, ...params) {
super(...params);
this.code = code;
}
}
// Mocks a single request:
// - if retVal is an integer it becomes an HTTP return code;
// - else it becomes a network error code.
const mockRequest = async (retVal) => {
if (Number.isInteger(retVal)) {
// HTTP response or failure, according to status code.
const response = { status: retVal };
if (retVal >= 400 && retVal <= 599) {
throw new HttpError(response);
}
return response;
}
// Otherwise network error.
throw new NetError(retVal);
};
// Returns a function that calls mockRequest() for each specified value
// in the rest parameter.
const mockAxios = (...returns) => {
const iter = returns[Symbol.iterator]();
return () => {
const { done, value } = iter.next();
if (done) {
throw new Error('Testing error: mockAxios iterator done');
}
return mockRequest(value);
};
};
test('retryAxios should return successful requests', async () => {
expect.assertions(1);
const data = await retryAxios([5, 5], mockAxios(200));
expect(data.status).toBe(200);
});
test('retryAxios should throw client errors', async () => {
expect.assertions(1);
try {
await retryAxios([5, 5], mockAxios(400));
} catch (e) {
expect(e.response.status).toBe(400);
}
});
test('retryAxios should throw non-transient network errors', async () => {
expect.assertions(1);
try {
await retryAxios([5, 5], mockAxios('CERT_REJECTED'));
} catch (e) {
expect(e.code).toBe('CERT_REJECTED');
}
});
test('retryAxios should call once even without retries', async () => {
expect.assertions(1);
const data = await retryAxios([], mockAxios(200));
expect(data.status).toBe(200);
});
test('retryAxios should return first successful call if within retry limit', async () => {
let data;
expect.assertions(2);
data = await retryAxios([5], mockAxios(500, 200));
expect(data.status).toBe(200);
data = await retryAxios([5, 5, 5, 5], mockAxios('ETIMEDOUT', 500, 'ECONNRESET', 500, 301));
expect(data.status).toBe(301);
});
test('retryAxios should throw the last error if all retries have been exhausted', async () => {
expect.assertions(1);
try {
await retryAxios(new ExponentialDelays(5, 2), mockAxios(500, 'ECONNRESET', 500, 200));
} catch (e) {
expect(e.response.status).toBe(500);
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment