Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(http): Support for Retry-After header #25859

Merged
merged 54 commits into from
Dec 14, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
d9f3d9b
feat(http): Support for `Retry-After` header
zharinov Nov 19, 2023
65a7e5d
Use `timers/promises`
zharinov Nov 19, 2023
3874140
Add maxRetryAfter option to host rules
zharinov Nov 19, 2023
7883e4b
Merge branch 'main' into feat/http-retry-after
zharinov Nov 19, 2023
1f1e1d3
Add doc
zharinov Nov 19, 2023
29beaf3
Update lib/config/options/index.ts
zharinov Nov 20, 2023
383b9f6
Merge branch 'main' into feat/http-retry-after
zharinov Nov 20, 2023
e473548
Add logging
zharinov Nov 20, 2023
9bebe1e
Apply suggestions from code review
zharinov Nov 21, 2023
fe9bf36
Fix error handling and retry logic in
zharinov Nov 21, 2023
8bef63a
Update docs/usage/configuration-options.md
zharinov Nov 23, 2023
75a9b59
Merge branch 'main' into feat/http-retry-after
zharinov Nov 23, 2023
2eefaff
Merge branch 'main' into feat/http-retry-after
zharinov Nov 23, 2023
19e3bdb
Merge branch 'main' into feat/http-retry-after
zharinov Nov 24, 2023
227872a
Merge branch 'main' into feat/http-retry-after
zharinov Nov 24, 2023
9b7b9d4
Merge branch 'main' into feat/http-retry-after
zharinov Nov 24, 2023
0a34a2d
Update docs/usage/configuration-options.md
zharinov Nov 26, 2023
aa1b080
Merge branch 'main' into feat/http-retry-after
zharinov Nov 26, 2023
69794b3
Merge branch 'main' into feat/http-retry-after
zharinov Nov 27, 2023
a777372
Set `maxRetryAfter` to zero for `got`
zharinov Nov 27, 2023
1e12517
Merge branch 'main' into feat/http-retry-after
zharinov Nov 27, 2023
5879e36
Merge branch 'main' into feat/http-retry-after
zharinov Nov 28, 2023
7268e10
Merge branch 'main' into feat/http-retry-after
zharinov Nov 30, 2023
5f503cf
Fix milliseconds
zharinov Nov 28, 2023
9958554
refactor(http): Separate host rule search and apply
zharinov Nov 30, 2023
186937c
Correct `maxRetryAfter` usage
zharinov Nov 30, 2023
b0732c4
Merge branch 'main' into feat/http-retry-after
zharinov Nov 30, 2023
75d23ee
Fix
zharinov Nov 30, 2023
c0c219b
Fix
zharinov Nov 30, 2023
2f94a23
Refactor
zharinov Nov 30, 2023
de7b3d5
Merge branch 'main' into feat/http-retry-after
zharinov Dec 1, 2023
3996c8a
Fix tests
zharinov Dec 1, 2023
22949b4
Merge branch 'main' into feat/http-retry-after
zharinov Dec 1, 2023
53c8a4d
Handle date format
zharinov Dec 1, 2023
598aad2
Fix lint
zharinov Dec 1, 2023
919f570
Delay accumulation
zharinov Dec 2, 2023
e9fcc16
Refactor
zharinov Dec 2, 2023
125d1ea
Fix
zharinov Dec 2, 2023
1a5f0b9
Merge branch 'main' into feat/http-retry-after
zharinov Dec 2, 2023
38471ce
Refactor
zharinov Dec 2, 2023
02e378f
Update lib/util/http/retry-after.ts
zharinov Dec 2, 2023
6f1b311
Merge branch 'main' into feat/http-retry-after
zharinov Dec 3, 2023
1a47277
Add comment and clarify status codes
zharinov Dec 3, 2023
959b31c
Fix comment
zharinov Dec 3, 2023
b0fc1d1
4xx
zharinov Dec 4, 2023
240f63d
Fix coverage
zharinov Dec 4, 2023
751fa9a
Fix lint
zharinov Dec 4, 2023
3356484
Add logger
zharinov Dec 5, 2023
b8bea88
Merge branch 'main' into feat/http-retry-after
zharinov Dec 5, 2023
fc6fe79
Update docs/usage/configuration-options.md
zharinov Dec 6, 2023
b7f7b64
Merge branch 'main' into feat/http-retry-after
zharinov Dec 8, 2023
c6a00d3
Merge branch 'main' into feat/http-retry-after
zharinov Dec 12, 2023
32a4738
Merge branch 'main' into feat/http-retry-after
zharinov Dec 13, 2023
079b95b
Merge branch 'main' into feat/http-retry-after
viceice Dec 14, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions lib/util/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import * as memCache from '../cache/memory';
import { clone } from '../clone';
import { hash } from '../hash';
import { type AsyncResult, Result } from '../result';
import { resolveBaseUrl } from '../url';
import { parseUrl, resolveBaseUrl } from '../url';
import { applyAuthorization, removeAuthorization } from './auth';
import { hooks } from './hooks';
import { applyHostRules } from './host-rules';
import { getQueue } from './queue';
import { extractRetryAfterHeaderSeconds, wrapWithRetry } from './retry-after';
import { Throttle, getThrottle } from './throttle';
import type {
GotJSONOptions,
Expand Down Expand Up @@ -225,7 +226,12 @@ export class Http<Opts extends HttpOptions = HttpOptions> {
? () => queue.add<HttpResponse<T>>(throttledTask)
: throttledTask;

resPromise = queuedTask();
const host = parseUrl(url)?.host ?? /* istanbul ignore next */ url;
resPromise = wrapWithRetry(
host,
queuedTask,
extractRetryAfterHeaderSeconds,
);

if (memCacheKey) {
memCache.set(memCacheKey, resPromise);
Expand Down
111 changes: 111 additions & 0 deletions lib/util/http/retry-after.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { RequestError } from 'got';
import { extractRetryAfterHeaderSeconds, wrapWithRetry } from './retry-after';

describe('util/http/retry-after', () => {
describe('wrapWithRetry', () => {
beforeEach(() => {
jest.useFakeTimers({ advanceTimers: true });
});

afterEach(() => {
jest.useRealTimers();
});

it('works', async () => {
const task = jest.fn(() => Promise.resolve(42));
const res = await wrapWithRetry('http://example.com', task, () => null);
expect(res).toBe(42);
expect(task).toHaveBeenCalledTimes(1);
});

it('throws', async () => {
const task = jest.fn(() => Promise.reject(new Error('foo')));

await expect(
wrapWithRetry('http://example.com', task, () => null),
).rejects.toThrow('foo');

expect(task).toHaveBeenCalledTimes(1);
});

it('retries', async () => {
const task = jest
.fn()
.mockRejectedValueOnce(new Error('foo'))
.mockRejectedValueOnce(new Error('bar'))
.mockResolvedValueOnce(42);

const p = wrapWithRetry('http://example.com', task, () => 1);
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('foo')
.mockRejectedValueOnce('bar')
.mockRejectedValueOnce('baz')
.mockRejectedValue('qux');

const p = wrapWithRetry('http://example.com', task, () => 1).catch(
(err) => err,
);
await jest.advanceTimersByTimeAsync(2000);

await expect(p).resolves.toBe('baz');
expect(task).toHaveBeenCalledTimes(3);
});
});

describe('extractRetryAfterHeaderSeconds', () => {
it('returns null for non-RequestError', () => {
expect(extractRetryAfterHeaderSeconds(new Error())).toBeNull();
});

it('returns null for RequestError without response', () => {
expect(
extractRetryAfterHeaderSeconds(
new RequestError('foo', {}, null as never),
),
).toBeNull();
});

it('returns null for status other than 429', () => {
const err = new RequestError('foo', {}, null as never);
(err as any).response = { statusCode: 302 };
expect(extractRetryAfterHeaderSeconds(err)).toBeNull();
});

it('returns null missing "retry-after" header', () => {
const err = new RequestError('foo', {}, null as never);
(err as any).response = { statusCode: 429, headers: {} };
expect(extractRetryAfterHeaderSeconds(err)).toBeNull();
});

it('returns null for non-integer "retry-after" header', () => {
const err = new RequestError('foo', {}, null as never);
(err as any).response = {
statusCode: 429,
headers: {
'retry-after': 'Wed, 21 Oct 2015 07:28:00 GMT',
},
};
expect(extractRetryAfterHeaderSeconds(err)).toBeNull();
});

it('returns delay in seconds', () => {
const err = new RequestError('foo', {}, null as never);
(err as any).response = {
statusCode: 429,
headers: {
'retry-after': '42',
},
};
expect(extractRetryAfterHeaderSeconds(err)).toBe(42);
});
});
});
67 changes: 67 additions & 0 deletions lib/util/http/retry-after.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { RequestError } from 'got';
import type { Task } from './types';

const hostBlocks = new Map<string, Promise<void>>();

const maxRetries = 2;

function sleep(seconds: number): Promise<void> {
return new Promise((resolve) => global.setTimeout(resolve, seconds * 1000));
}
zharinov marked this conversation as resolved.
Show resolved Hide resolved

export function extractRetryAfterHeaderSeconds(err: unknown): number | null {
if (!(err instanceof RequestError)) {
return null;
}

if (!err.response) {
return null;
}

if (err.response.statusCode !== 429) {
return null;
}

const retryAfter = err.response.headers['retry-after'];
if (!retryAfter) {
return null;
}

const seconds = parseInt(retryAfter, 10);
zharinov marked this conversation as resolved.
Show resolved Hide resolved
if (Number.isNaN(seconds)) {
return null;
}

return seconds;
}

export async function wrapWithRetry<T>(
key: string,
task: Task<T>,
getDelaySeconds: (err: unknown) => number | null,
): Promise<T> {
let retries = 0;
for (;;) {
try {
const delayPromise = hostBlocks.get(key);
if (delayPromise) {
await delayPromise;
hostBlocks.delete(key);
rarkins marked this conversation as resolved.
Show resolved Hide resolved
}

return await task();
} catch (err) {
if (retries === maxRetries) {
throw err;
}

const delaySeconds = getDelaySeconds(err);
if (delaySeconds === null) {
throw err;
}

hostBlocks.set(key, sleep(delaySeconds));
retries += 1;
}
}
}
3 changes: 2 additions & 1 deletion lib/util/http/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,5 @@ export interface HttpResponse<T = string> {
authorization?: boolean;
}

export type GotTask<T> = () => Promise<HttpResponse<T>>;
export type Task<T> = () => Promise<T>;
export type GotTask<T> = Task<HttpResponse<T>>;