Skip to content

Commit 1009fb2

Browse files
authored
fix(error handling): applies proper axios error handling (#24)
1 parent 2386138 commit 1009fb2

File tree

2 files changed

+87
-29
lines changed

2 files changed

+87
-29
lines changed

lib/rate-limit.js

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,47 @@
1-
export default function rateLimit (instance, maxRetry = 5) {
2-
let attempt = 0
3-
let lastErrorAt
1+
const attempts = {}
42

3+
export default function rateLimit (instance, maxRetry = 5) {
54
instance.interceptors.response.use(function (response) {
65
// we don't need to do anything here
76
return response
87
}, function (error) {
9-
const {config} = error
10-
if (!config || !instance.defaults.retryOnError) {
8+
const {request, response, config} = error
9+
if ((!request && !response) || !config || !instance.defaults.retryOnError) {
1110
return Promise.reject(error)
1211
}
13-
// in axios reponse will be available only if there a response from server
14-
// in this case will assume it is a 500 error
15-
error.response = error.response || {status: 500}
16-
if (error.response.status === 500) {
17-
attempt++
18-
lastErrorAt = lastErrorAt || Date.now()
19-
lastErrorAt = Date.now()
20-
// we reject if there are too much errors in a short period of time
21-
if (attempt >= maxRetry) {
12+
13+
let retryErrorType = null
14+
let wait = 0
15+
16+
if (!response) {
17+
// Errors without response or config did not recieve anything from the server
18+
retryErrorType = 'Connection'
19+
} else if (response.status >= 500 && response.status < 600) {
20+
// 5** errors are server related
21+
retryErrorType = `Server ${response.status}`
22+
const headers = response.headers || {}
23+
const requestId = headers['x-contentful-request-id'] || null
24+
attempts[requestId] = attempts[requestId] || 0
25+
attempts[requestId]++
26+
27+
// we reject if there are too much errors of with the same request id
28+
if (attempts[requestId] >= maxRetry || !requestId) {
2229
return Promise.reject(error)
2330
}
24-
}
25-
26-
// retry if we receive 429 or 500
27-
if (error.response.status === 429 || error.response.status === 500) {
28-
let rateLimitHeaderValue = 0
31+
wait = Math.pow(Math.SQRT2, attempts[requestId])
32+
} else if (response.status === 429) {
33+
// 429 errors are exceeded rate limit exceptions
34+
retryErrorType = 'Rate limit'
2935
// all headers are lowercased by axios https://github.com/mzabriskie/axios/issues/413
30-
if (error.response.headers && error.response.headers['x-contentful-ratelimit-reset']) {
31-
rateLimitHeaderValue = error.response.headers['x-contentful-ratelimit-reset']
36+
if (response.headers && error.response.headers['x-contentful-ratelimit-reset']) {
37+
wait = response.headers['x-contentful-ratelimit-reset']
3238
}
33-
let wait = rateLimitHeaderValue > 0 ? rateLimitHeaderValue : Math.pow(Math.SQRT2, attempt)
39+
}
40+
41+
if (retryErrorType) {
3442
// convert to ms and add jitter
3543
wait = Math.floor(wait * 1000 + (Math.random() * 200) + 500)
36-
console.log(`${error.response.status === 429 ? 'Rate limit' : 'Server'} error occured. Waiting for ${wait} ms before retrying....`)
44+
console.log(`${retryErrorType} error occured. Waiting for ${wait} ms before retrying....`)
3745
return new Promise((resolve) => {
3846
setTimeout(() => {
3947
resolve(instance(config))

test/unit/rate-limit-test.js

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,31 @@ test('Retry on 429 after a duration >= rateLimit header', (t) => {
3737
teardown()
3838
})
3939
})
40-
test('Retry on 500', (t) => {
40+
test('Retry on 5** - multiple errors', (t) => {
4141
const { client } = setup()
42-
mock.onGet('/rate-limit-me').replyOnce(500)
43-
mock.onGet('/rate-limit-me').replyOnce(500)
42+
mock.onGet('/rate-limit-me').replyOnce(500, 'Server Error', {'x-contentful-request-id': 1})
43+
mock.onGet('/rate-limit-me').replyOnce(500, 'Server Error', {'x-contentful-request-id': 1})
44+
mock.onGet('/rate-limit-me').replyOnce(200, 'works #1')
45+
mock.onGet('/rate-limit-me').replyOnce(503, 'Another Server Error', {'x-contentful-request-id': 2})
46+
mock.onGet('/rate-limit-me').replyOnce(200, 'works #2')
47+
t.plan(5)
48+
return client.get('/rate-limit-me').then((response) => {
49+
t.ok(response.data)
50+
t.equals(response.data, 'works #1')
51+
const startTime = Date.now()
52+
return client.get('/rate-limit-me').then((response) => {
53+
t.ok(Date.now() - startTime <= 3000, 'First error should not influence second errors retry delay')
54+
t.ok(response.data)
55+
t.equals(response.data, 'works #2')
56+
teardown()
57+
})
58+
})
59+
})
60+
// Disabled till new version of axios-mock-adapter is out
61+
// https://github.com/ctimmerm/axios-mock-adapter/issues/52
62+
test.skip('Retry on network error', (t) => {
63+
const { client } = setup()
64+
mock.onGet('/rate-limit-me').networkError()
4465
mock.onGet('/rate-limit-me').replyOnce(200, 'works')
4566
t.plan(2)
4667
return client.get('/rate-limit-me').then((response) => {
@@ -52,7 +73,7 @@ test('Retry on 500', (t) => {
5273
test('no retry when automatic handling flag is disabled', (t) => {
5374
const { client } = setupWithoutErrorRetry()
5475
const responseError = new Error('Mocked 500 Error')
55-
mock.onGet('/rate-limit-me').replyOnce(500, responseError)
76+
mock.onGet('/rate-limit-me').replyOnce(500, responseError, {'x-contentful-request-id': 3})
5677
t.plan(2)
5778
return client.get('/rate-limit-me').then((response) => {
5879
t.fail('Promise should reject not resolve')
@@ -66,14 +87,43 @@ test('no retry when automatic handling flag is disabled', (t) => {
6687
})
6788
test('Should Fail if it hits maxRetries', (t) => {
6889
const { client } = setupWithOneRetry()
69-
mock.onGet('/error').replyOnce(500, 'error attempt #1')
70-
mock.onGet('/error').replyOnce(500, 'error attempt #2')
90+
mock.onGet('/error').replyOnce(500, 'error attempt #1', {'x-contentful-request-id': 4})
91+
mock.onGet('/error').replyOnce(500, 'error attempt #2', {'x-contentful-request-id': 4})
7192
t.plan(2)
7293
return client.get('/error').then((response) => {
7394
t.fail('the request should return error')
95+
teardown()
7496
}).catch((error) => {
7597
t.ok(error)
7698
t.equals(error.response.data, 'error attempt #2')
7799
teardown()
78100
})
79101
})
102+
test('Rejects error straight away when X-Contentful-Request-Id header is missing', (t) => {
103+
const { client } = setupWithOneRetry()
104+
mock.onGet('/error').replyOnce(500, 'error attempt')
105+
mock.onGet('/error').replyOnce(200, 'works')
106+
t.plan(2)
107+
return client.get('/error').then((response) => {
108+
t.fail('the request should return error')
109+
teardown()
110+
}).catch((error) => {
111+
t.ok(error)
112+
t.equals(error.response.data, 'error attempt')
113+
teardown()
114+
})
115+
})
116+
test('Rejects errors with strange status codes', (t) => {
117+
const { client } = setupWithOneRetry()
118+
mock.onGet('/error').replyOnce(765, 'error attempt')
119+
mock.onGet('/error').replyOnce(200, 'works')
120+
t.plan(2)
121+
return client.get('/error').then((response) => {
122+
t.fail('the request should return error')
123+
teardown()
124+
}).catch((error) => {
125+
t.ok(error)
126+
t.equals(error.response.data, 'error attempt')
127+
teardown()
128+
})
129+
})

0 commit comments

Comments
 (0)