forked from renovatebot/renovate
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(http): Support for
Retry-After
header (renovatebot#25859)
Co-authored-by: Rhys Arkins <[email protected]> Co-authored-by: HonkingGoose <[email protected]> Co-authored-by: Michael Kriese <[email protected]> Co-authored-by: Sebastian Poxhofer <[email protected]>
- Loading branch information
1 parent
2af38bb
commit 26ff560
Showing
7 changed files
with
322 additions
and
7 deletions.
There are no files selected for viewing
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
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
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
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
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,162 @@ | ||
import { RequestError } from 'got'; | ||
import { getRetryAfter, wrapWithRetry } from './retry-after'; | ||
|
||
function requestError( | ||
response: { | ||
statusCode?: number; | ||
headers?: Record<string, string | string[]>; | ||
} | null = null, | ||
) { | ||
const err = new RequestError('request error', {}, null as never); | ||
if (response) { | ||
(err as any).response = response; | ||
} | ||
return err; | ||
} | ||
|
||
describe('util/http/retry-after', () => { | ||
beforeEach(() => { | ||
jest.useFakeTimers(); | ||
}); | ||
|
||
afterEach(() => { | ||
jest.useRealTimers(); | ||
}); | ||
|
||
describe('wrapWithRetry', () => { | ||
it('works', async () => { | ||
const task = jest.fn(() => Promise.resolve(42)); | ||
const res = await wrapWithRetry(task, 'foobar', () => null, 60); | ||
expect(res).toBe(42); | ||
expect(task).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('throws', async () => { | ||
const task = jest.fn(() => Promise.reject(new Error('error'))); | ||
|
||
await expect( | ||
wrapWithRetry(task, 'http://example.com', () => null, 60), | ||
).rejects.toThrow('error'); | ||
|
||
expect(task).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('retries', async () => { | ||
const task = jest | ||
.fn() | ||
.mockRejectedValueOnce(new Error('error-1')) | ||
.mockRejectedValueOnce(new Error('error-2')) | ||
.mockResolvedValueOnce(42); | ||
|
||
const p = wrapWithRetry(task, 'http://example.com', () => 1, 60); | ||
await jest.advanceTimersByTimeAsync(2000); | ||
|
||
const res = await p; | ||
expect(res).toBe(42); | ||
expect(task).toHaveBeenCalledTimes(3); | ||
}); | ||
|
||
it('gives up after max retries', async () => { | ||
const task = jest | ||
.fn() | ||
.mockRejectedValueOnce('error-1') | ||
.mockRejectedValueOnce('error-2') | ||
.mockRejectedValueOnce('error-3') | ||
.mockRejectedValue('error-4'); | ||
|
||
const p = wrapWithRetry(task, 'http://example.com', () => 1, 60).catch( | ||
(err) => err, | ||
); | ||
await jest.advanceTimersByTimeAsync(2000); | ||
|
||
await expect(p).resolves.toBe('error-3'); | ||
expect(task).toHaveBeenCalledTimes(3); | ||
}); | ||
|
||
it('gives up when delay exceeds maxRetryAfter', async () => { | ||
const task = jest.fn().mockRejectedValue('error'); | ||
|
||
const p = wrapWithRetry(task, 'http://example.com', () => 61, 60).catch( | ||
(err) => err, | ||
); | ||
|
||
await expect(p).resolves.toBe('error'); | ||
expect(task).toHaveBeenCalledTimes(1); | ||
}); | ||
}); | ||
|
||
describe('getRetryAfter', () => { | ||
it('returns null for non-RequestError', () => { | ||
expect(getRetryAfter(new Error())).toBeNull(); | ||
}); | ||
|
||
it('returns null for RequestError without response', () => { | ||
expect(getRetryAfter(requestError())).toBeNull(); | ||
}); | ||
|
||
it('returns null for status other than 429', () => { | ||
const err = new RequestError('request-error', {}, null as never); | ||
(err as any).response = { statusCode: 302 }; | ||
expect(getRetryAfter(requestError({ statusCode: 302 }))).toBeNull(); | ||
}); | ||
|
||
it('returns null missing "retry-after" header', () => { | ||
expect( | ||
getRetryAfter(requestError({ statusCode: 429, headers: {} })), | ||
).toBeNull(); | ||
}); | ||
|
||
it('returns null for non-integer "retry-after" header', () => { | ||
expect( | ||
getRetryAfter( | ||
requestError({ | ||
statusCode: 429, | ||
headers: { | ||
'retry-after': 'Wed, 21 Oct 2015 07:28:00 GMT', | ||
}, | ||
}), | ||
), | ||
).toBeNull(); | ||
}); | ||
|
||
it('returns delay in seconds from date', () => { | ||
jest.setSystemTime(new Date('2020-01-01T00:00:00Z')); | ||
expect( | ||
getRetryAfter( | ||
requestError({ | ||
statusCode: 429, | ||
headers: { | ||
'retry-after': 'Wed, 01 Jan 2020 00:00:42 GMT', | ||
}, | ||
}), | ||
), | ||
).toBe(42); | ||
}); | ||
|
||
it('returns delay in seconds from number', () => { | ||
expect( | ||
getRetryAfter( | ||
requestError({ | ||
statusCode: 429, | ||
headers: { | ||
'retry-after': '42', | ||
}, | ||
}), | ||
), | ||
).toBe(42); | ||
}); | ||
|
||
it('returns null for invalid header value', () => { | ||
expect( | ||
getRetryAfter( | ||
requestError({ | ||
statusCode: 429, | ||
headers: { | ||
'retry-after': 'invalid', | ||
}, | ||
}), | ||
), | ||
).toBeNull(); | ||
}); | ||
}); | ||
}); |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
import { setTimeout } from 'timers/promises'; | ||
import { RequestError } from 'got'; | ||
import { DateTime } from 'luxon'; | ||
import { logger } from '../../logger'; | ||
import { parseUrl } from '../url'; | ||
import type { Task } from './types'; | ||
|
||
const hostDelays = new Map<string, Promise<unknown>>(); | ||
|
||
const maxRetries = 2; | ||
|
||
/** | ||
* Given a task that returns a promise, retry the task if it fails with a | ||
* 429 Too Many Requests or 403 Forbidden response, using the Retry-After | ||
* header to determine the delay. | ||
* | ||
* For response codes other than 429 or 403, or if the Retry-After header | ||
* is not present or invalid, the task is not retried, throwing the error. | ||
*/ | ||
export async function wrapWithRetry<T>( | ||
task: Task<T>, | ||
url: string, | ||
getRetryAfter: (err: unknown) => number | null, | ||
maxRetryAfter: number, | ||
): Promise<T> { | ||
const key = parseUrl(url)?.host ?? url; | ||
|
||
let retries = 0; | ||
for (;;) { | ||
try { | ||
await hostDelays.get(key); | ||
hostDelays.delete(key); | ||
|
||
return await task(); | ||
} catch (err) { | ||
const delaySeconds = getRetryAfter(err); | ||
if (delaySeconds === null) { | ||
throw err; | ||
} | ||
|
||
if (retries === maxRetries) { | ||
logger.debug( | ||
`Retry-After: reached maximum retries (${maxRetries}) for ${url}`, | ||
); | ||
throw err; | ||
} | ||
|
||
if (delaySeconds > maxRetryAfter) { | ||
logger.debug( | ||
`Retry-After: delay ${delaySeconds} seconds exceeds maxRetryAfter ${maxRetryAfter} seconds for ${url}`, | ||
); | ||
throw err; | ||
} | ||
|
||
logger.debug( | ||
`Retry-After: will retry ${url} after ${delaySeconds} seconds`, | ||
); | ||
|
||
const delay = Promise.all([ | ||
hostDelays.get(key), | ||
setTimeout(1000 * delaySeconds), | ||
]); | ||
hostDelays.set(key, delay); | ||
retries += 1; | ||
} | ||
} | ||
} | ||
|
||
export function getRetryAfter(err: unknown): number | null { | ||
if (!(err instanceof RequestError)) { | ||
return null; | ||
} | ||
|
||
if (!err.response) { | ||
return null; | ||
} | ||
|
||
if (err.response.statusCode < 400 || err.response.statusCode >= 500) { | ||
logger.warn( | ||
{ url: err.response.url }, | ||
`Retry-After: unexpected status code ${err.response.statusCode}`, | ||
); | ||
return null; | ||
} | ||
|
||
const retryAfter = err.response.headers['retry-after']?.trim(); | ||
if (!retryAfter) { | ||
return null; | ||
} | ||
|
||
const date = DateTime.fromHTTP(retryAfter); | ||
if (date.isValid) { | ||
const seconds = Math.floor(date.diffNow('seconds').seconds); | ||
if (seconds < 0) { | ||
logger.debug( | ||
{ url: err.response.url, retryAfter }, | ||
'Retry-After: date in the past', | ||
); | ||
return null; | ||
} | ||
|
||
return seconds; | ||
} | ||
|
||
const seconds = parseInt(retryAfter, 10); | ||
if (!Number.isNaN(seconds) && seconds > 0) { | ||
return seconds; | ||
} | ||
|
||
logger.debug( | ||
{ url: err.response.url, retryAfter }, | ||
'Retry-After: unsupported format', | ||
); | ||
return null; | ||
} |
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