Retry Axios requests: see https://blog.michaelstrasser.com/2017/11/retrying-backend-service-calls-with-backoff/ for details
{ | |
"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