Last active
March 1, 2020 21:05
-
-
Save mjstrasser/69d0d03f0eedb513ae529a0c137800f1 to your computer and use it in GitHub Desktop.
Retry Axios requests: see https://blog.michaelstrasser.com/2017/11/retrying-backend-service-calls-with-backoff/ for details
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"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" | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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