Skip to content

Commit 3c998b6

Browse files
nicdardNicola Dardanisshazronsangeetha5491
authored
feat: add retry-after header support (#22)
Co-authored-by: Nicola Dardanis <ndardanis@adobe.com> Co-authored-by: Shazron Abdullah <36107+shazron@users.noreply.github.com> Co-authored-by: Sangeetha Krishnan <sangeetha5491@gmail.com>
1 parent 762fc54 commit 3c998b6

File tree

6 files changed

+149
-20
lines changed

6 files changed

+149
-20
lines changed

README.md

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ with defaults set to max of 3 retries and initial Delay as 100ms</p>
9494
<dt><a href="#createFetch">createFetch([proxyAuthOptions])</a> ⇒ <code>function</code></dt>
9595
<dd><p>Return the appropriate Fetch function depending on proxy settings.</p>
9696
</dd>
97+
<dt><a href="#parseRetryAfterHeader">parseRetryAfterHeader(header)</a> ⇒ <code>number</code></dt>
98+
<dd><p>Parse the Retry-After header
99+
Spec: <a href="https://tools.ietf.org/html/rfc7231#section-7.1.3">https://tools.ietf.org/html/rfc7231#section-7.1.3</a></p>
100+
</dd>
97101
</dl>
98102

99103
## Typedefs
@@ -141,7 +145,6 @@ This provides a wrapper for fetch that facilitates proxy auth authorization.
141145

142146
* [ProxyFetch](#ProxyFetch)
143147
* [new ProxyFetch(authOptions)](#new_ProxyFetch_new)
144-
* [.proxyAgent()](#ProxyFetch+proxyAgent) ⇒ <code>http.Agent</code>
145148
* [.fetch(resource, options)](#ProxyFetch+fetch) ⇒ <code>Promise.&lt;Response&gt;</code>
146149

147150
<a name="new_ProxyFetch_new"></a>
@@ -154,13 +157,6 @@ Initialize this class with Proxy auth options
154157
| --- | --- | --- |
155158
| authOptions | [<code>ProxyAuthOptions</code>](#ProxyAuthOptions) | the auth options to connect with |
156159

157-
<a name="ProxyFetch+proxyAgent"></a>
158-
159-
### proxyFetch.proxyAgent() ⇒ <code>http.Agent</code>
160-
Returns the http.Agent used for this proxy
161-
162-
**Kind**: instance method of [<code>ProxyFetch</code>](#ProxyFetch)
163-
**Returns**: <code>http.Agent</code> - a http.Agent for basic auth proxy
164160
<a name="ProxyFetch+fetch"></a>
165161

166162
### proxyFetch.fetch(resource, options) ⇒ <code>Promise.&lt;Response&gt;</code>
@@ -186,6 +182,19 @@ Return the appropriate Fetch function depending on proxy settings.
186182
| --- | --- | --- |
187183
| [proxyAuthOptions] | [<code>ProxyAuthOptions</code>](#ProxyAuthOptions) | the proxy auth options |
188184

185+
<a name="parseRetryAfterHeader"></a>
186+
187+
## parseRetryAfterHeader(header) ⇒ <code>number</code>
188+
Parse the Retry-After header
189+
Spec: [https://tools.ietf.org/html/rfc7231#section-7.1.3](https://tools.ietf.org/html/rfc7231#section-7.1.3)
190+
191+
**Kind**: global function
192+
**Returns**: <code>number</code> - Number of milliseconds to sleep until the next call to getEventsFromJournal
193+
194+
| Param | Type | Description |
195+
| --- | --- | --- |
196+
| header | <code>string</code> | Retry-After header value |
197+
189198
<a name="RetryOptions"></a>
190199

191200
## RetryOptions : <code>object</code>

src/HttpExponentialBackoff.js

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const DEFAULT_MAX_RETRIES = 3
1313
const DEFAULY_INITIAL_DELAY_MS = 100
1414
const loggerNamespace = '@adobe/aio-lib-core-networking:HttpExponentialBackoff'
1515
const logger = require('@adobe/aio-lib-core-logging')(loggerNamespace, { level: process.env.LOG_LEVEL })
16-
const { createFetch } = require('./utils')
16+
const { createFetch, parseRetryAfterHeader } = require('./utils')
1717

1818
/* global Request, Response, ProxyAuthOptions */ // for linter
1919

@@ -89,7 +89,7 @@ class HttpExponentialBackoff {
8989
__getRetryOptions (retries, initialDelayInMillis) {
9090
return {
9191
retryOn: this.__getRetryOn(retries),
92-
retryDelay: this.__getRetryDelay(initialDelayInMillis)
92+
retryDelay: this.__getRetryDelayWithRetryAfterHeader(initialDelayInMillis)
9393
}
9494
}
9595

@@ -103,7 +103,7 @@ class HttpExponentialBackoff {
103103
*/
104104
__getRetryOn (retries) {
105105
return function (attempt, error, response) {
106-
if (attempt < retries && (error !== null || (response.status > 499 && response.status < 600))) {
106+
if (attempt < retries && (error !== null || (response.status > 499 && response.status < 600) || response.status === 429)) {
107107
const msg = `Retrying after attempt ${attempt + 1}. failed: ${error || response.statusText}`
108108
logger.debug(msg)
109109
return true
@@ -127,6 +127,27 @@ class HttpExponentialBackoff {
127127
return timeToWait
128128
}
129129
}
130+
131+
/**
132+
* Retry Delay returns a function that either:
133+
* - return the value of Retry-After header, if present
134+
* - implements exponential backoff otherwise
135+
*
136+
* @param {number} initialDelayInMillis The multiplying factor and the initial delay. Eg. 100 would mean the retries would be spaced at 100, 200, 400, .. ms
137+
* @returns {Function} retryDelayFunction {function(*=, *, *): number}
138+
* @private
139+
*/
140+
__getRetryDelayWithRetryAfterHeader (initialDelayInMillis) {
141+
return (attempt, error, response) => {
142+
const retryAfter = response.headers.get('Retry-After')
143+
const timeToWait = parseRetryAfterHeader(retryAfter)
144+
if (!isNaN(timeToWait)) {
145+
logger.debug(`Request will be retried after ${timeToWait} ms`)
146+
return timeToWait
147+
}
148+
return this.__getRetryDelay(initialDelayInMillis)(attempt, error, response)
149+
}
150+
}
130151
}
131152

132153
module.exports = HttpExponentialBackoff

src/utils.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,33 @@ function urlToHttpOptions (aUrl) {
8282
return options
8383
}
8484

85+
/**
86+
* Parse the Retry-After header
87+
* Spec: {@link https://tools.ietf.org/html/rfc7231#section-7.1.3}
88+
*
89+
* @param {string} header Retry-After header value
90+
* @returns {number} Number of milliseconds to sleep until the next call to getEventsFromJournal
91+
*/
92+
function parseRetryAfterHeader (header) {
93+
if (header == null) {
94+
return NaN
95+
}
96+
if (header.match(/^[0-9]+$/)) {
97+
const delta = parseInt(header, 10) * 1000
98+
return delta <= 0 ? NaN : delta
99+
}
100+
if (header.match(/^-[0-9]+$/)) {
101+
return NaN
102+
}
103+
const dateMs = Date.parse(header)
104+
const delta = dateMs - Date.now()
105+
return isNaN(delta) || delta <= 0
106+
? NaN
107+
: delta
108+
}
109+
85110
module.exports = {
86111
urlToHttpOptions,
87-
createFetch
112+
createFetch,
113+
parseRetryAfterHeader
88114
}

test/HttpExponentialBackoff.test.js

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ governing permissions and limitations under the License.
1212
const HttpExponentialBackoff = require('../src/HttpExponentialBackoff')
1313
const fetchClient = new HttpExponentialBackoff()
1414
const fetchMock = require('node-fetch')
15+
const { parseRetryAfterHeader } = require('../src/utils')
1516
jest.mock('node-fetch')
1617

1718
/**
@@ -25,7 +26,7 @@ jest.mock('node-fetch')
2526
*/
2627
function __testRetryOnHelper (retries, low = 499, high = 600) {
2728
return jest.fn().mockImplementation(function (attempt, error, response) {
28-
if (attempt < retries && (error !== null || (response.status > low && response.status < high))) {
29+
if (attempt < retries && (error !== null || (response.status > low && response.status < high) || response.status === 429)) {
2930
return true
3031
}
3132
return false
@@ -41,6 +42,11 @@ function __testRetryOnHelper (retries, low = 499, high = 600) {
4142
*/
4243
function __testRetryDelayHelper (initialDelay) {
4344
return jest.fn().mockImplementation(function (attempt, error, response) {
45+
const retryAfter = response.headers.get('Retry-After')
46+
const timeToWait = parseRetryAfterHeader(retryAfter)
47+
if (!isNaN(timeToWait)) {
48+
return timeToWait
49+
}
4450
return attempt * initialDelay// 1000, 2000, 4000
4551
})
4652
}
@@ -107,6 +113,19 @@ test('test exponentialBackoff with no retries on 4xx errors and default retry st
107113
retrySpy.mockRestore()
108114
})
109115

116+
test('test exponentialBackoff with 3 retries on 429 errors and default retry strategy', async () => {
117+
const mockDefaultFn = __testRetryOnHelper(3)
118+
const retrySpy = jest.spyOn(fetchClient, '__getRetryOn').mockImplementation((retries) => mockDefaultFn)
119+
fetchMock.mockResponse('429 Too many requests', {
120+
status: 429
121+
})
122+
const result = await fetchClient.exponentialBackoff('https://abc1.com/', { method: 'GET' }, { initialDelayInMillis: 10 })
123+
expect(result.status).toBe(429)
124+
expect(retrySpy).toHaveBeenCalledWith(3)
125+
expect(mockDefaultFn).toHaveBeenCalledTimes(4)
126+
retrySpy.mockRestore()
127+
})
128+
110129
test('test exponentialBackoff with 3 retries on 5xx errors and default retry strategy', async () => {
111130
const mockDefaultFn = __testRetryOnHelper(3)
112131
const retrySpy = jest.spyOn(fetchClient, '__getRetryOn').mockImplementation((retries) => {
@@ -122,6 +141,20 @@ test('test exponentialBackoff with 3 retries on 5xx errors and default retry str
122141
retrySpy.mockRestore()
123142
})
124143

144+
test('test exponentialBackoff with 3 retries on errors with default retry strategy and date in Retry-After header', async () => {
145+
const spy = jest.spyOn(global.Date, 'now').mockImplementation(() => new Date('Mon, 13 Feb 2023 23:59:59 GMT'))
146+
const header = 'Tue, 14 Feb 2023 00:00:00 GMT'
147+
fetchMock.mockResponse('503 Service Unavailable', {
148+
status: 503,
149+
headers: {
150+
'Retry-After': header
151+
}
152+
})
153+
const result = await fetchClient.exponentialBackoff('https://abc2.com/', { method: 'GET' }, { maxRetries: 2 })
154+
expect(result.status).toBe(503)
155+
expect(spy).toHaveBeenCalledTimes(2)
156+
})
157+
125158
test('test exponential backoff with success in first attempt and custom retryOptions', async () => {
126159
const mockDefaultFn = __testRetryOnHelper(2)
127160
const retrySpy = jest.spyOn(fetchClient, '__getRetryOn').mockImplementation((retries) => {
@@ -215,7 +248,8 @@ test('test exponentialBackoff with default 3 retries on 5xx errors and custom re
215248
test('test exponentialBackoff with 3 retries on 5xx errors and custom retryDelay', async () => {
216249
const mockDefaultFn1 = __testRetryDelayHelper(100)
217250
fetchMock.mockResponse('503 Service Unavailable', {
218-
status: 503
251+
status: 503,
252+
headers: {}
219253
})
220254
const result = await fetchClient.exponentialBackoff('https://abc2.com/', { method: 'GET' }, { maxRetries: 2 }, undefined, mockDefaultFn1)
221255
expect(result.status).toBe(503)

test/utils.test.js

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ OF ANY KIND, either express or implied. See the License for the specific languag
99
governing permissions and limitations under the License.
1010
*/
1111

12-
const { urlToHttpOptions, createFetch } = require('../src/utils')
12+
const { urlToHttpOptions, createFetch, parseRetryAfterHeader } = require('../src/utils')
1313
const { ProxyFetch } = require('../src/index')
1414
const { getProxyForUrl } = require('proxy-from-env')
1515

@@ -21,6 +21,7 @@ jest.mock('node-fetch')
2121
test('exports', () => {
2222
expect(typeof urlToHttpOptions).toEqual('function')
2323
expect(typeof createFetch).toEqual('function')
24+
expect(typeof parseRetryAfterHeader).toEqual('function')
2425
})
2526

2627
test('url test (undefined)', () => {
@@ -125,3 +126,38 @@ describe('createFetch', () => {
125126
expect(response.status).toEqual(result.status)
126127
})
127128
})
129+
130+
describe('parseRetryAfterHeader', () => {
131+
test('null retry after', () => {
132+
const header = 'null'
133+
expect(parseRetryAfterHeader(header)).toEqual(NaN)
134+
})
135+
test('positive integer retry-after header', () => {
136+
const header = '23'
137+
expect(parseRetryAfterHeader(header)).toEqual(23000)
138+
})
139+
test('negative integer retry-after header', () => {
140+
const header = '-23'
141+
expect(parseRetryAfterHeader(header)).toEqual(NaN)
142+
})
143+
test('retry-after header is 0', () => {
144+
const header = '0'
145+
expect(parseRetryAfterHeader(header)).toEqual(NaN)
146+
})
147+
test('date retry-after header', () => {
148+
const spy = jest.spyOn(global.Date, 'now').mockImplementation(() => new Date('Mon, 13 Feb 2023 23:59:59 GMT'))
149+
const header = 'Tue, 14 Feb 2023 00:00:00 GMT'
150+
expect(parseRetryAfterHeader(header)).toEqual(1000)
151+
expect(spy).toHaveBeenCalled()
152+
})
153+
test('date retry-after header older than now', () => {
154+
const spy = jest.spyOn(global.Date, 'now').mockImplementation(() => new Date('Tue, 14 Feb 2023 00:00:00 GMT'))
155+
const header = 'Mon, 13 Feb 2023 23:59:59 GMT'
156+
expect(parseRetryAfterHeader(header)).toEqual(NaN)
157+
expect(spy).toHaveBeenCalled()
158+
})
159+
test('invalid retry-after header', () => {
160+
const header = 'not::a::date'
161+
expect(parseRetryAfterHeader(header)).toEqual(NaN)
162+
})
163+
})

types.d.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,6 @@ declare type ProxyAuthOptions = {
4545
*/
4646
declare class ProxyFetch {
4747
constructor(authOptions: ProxyAuthOptions);
48-
/**
49-
* Returns the http.Agent used for this proxy
50-
* @returns a http.Agent for basic auth proxy
51-
*/
52-
proxyAgent(): http.Agent;
5348
/**
5449
* Fetch function, using the configured NTLM Auth options.
5550
* @param resource - the url or Request object to fetch from
@@ -66,3 +61,11 @@ declare class ProxyFetch {
6661
*/
6762
declare function createFetch(proxyAuthOptions?: ProxyAuthOptions): (...params: any[]) => any;
6863

64+
/**
65+
* Parse the Retry-After header
66+
* Spec: {@link https://tools.ietf.org/html/rfc7231#section-7.1.3}
67+
* @param header - Retry-After header value
68+
* @returns Number of milliseconds to sleep until the next call to getEventsFromJournal
69+
*/
70+
declare function parseRetryAfterHeader(header: string): number;
71+

0 commit comments

Comments
 (0)