Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 28 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ export class LinkChecker extends EventEmitter {
response = await makeRequest('GET', options.url.href, {
headers: options.checkOptions.headers,
timeout: options.checkOptions.timeout,
redirect: redirectMode,
});
if (this.shouldRetryAfter(response, options)) {
return;
Expand All @@ -321,6 +322,7 @@ export class LinkChecker extends EventEmitter {
response = await makeRequest('GET', options.url.href, {
headers: options.checkOptions.headers,
timeout: options.checkOptions.timeout,
redirect: redirectMode,
});
if (this.shouldRetryAfter(response, options)) {
return;
Expand Down Expand Up @@ -554,6 +556,30 @@ export class LinkChecker extends EventEmitter {
}
}

/**
* Parse the retry-after header value into a timestamp.
* Supports standard formats (seconds, HTTP date) and non-standard formats (30s, 1m30s).
* @param retryAfterRaw Raw retry-after header value
* @returns Timestamp in milliseconds when to retry, or NaN if invalid
*/
private parseRetryAfter(retryAfterRaw: string): number {
// Try parsing as seconds
let retryAfter = Number(retryAfterRaw) * 1000 + Date.now();
if (!Number.isNaN(retryAfter)) return retryAfter;

// Try parsing as HTTP date
retryAfter = Date.parse(retryAfterRaw);
if (!Number.isNaN(retryAfter)) return retryAfter;

// Handle non-standard formats like "30s" or "1m30s"
const matches = retryAfterRaw.match(/^(?:(\d+)m)?(\d+)s$/);
if (!matches) return Number.NaN;

return (
(Number(matches[1] || 0) * 60 + Number(matches[2])) * 1000 + Date.now()
);
}

/**
* Check the incoming response for a `retry-after` header. If present,
* and if the status was an HTTP 429, calculate the date at which this
Expand All @@ -572,14 +598,9 @@ export class LinkChecker extends EventEmitter {
return false;
}

// The `retry-after` header can come in either <seconds> or
// A specific date to go check.
let retryAfter = Number(retryAfterRaw) * 1000 + Date.now();
const retryAfter = this.parseRetryAfter(retryAfterRaw);
if (Number.isNaN(retryAfter)) {
retryAfter = Date.parse(retryAfterRaw);
if (Number.isNaN(retryAfter)) {
return false;
}
return false;
}

// Check to see if there is already a request to wait for this host
Expand Down
40 changes: 40 additions & 0 deletions test/test.retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,46 @@ describe('retries', () => {
assert.ok(results.passed);
});

it('should retry 429s with non-standard seconds format (3s)', async () => {
const mockPool = mockAgent.get('http://example.invalid');
mockPool.intercept({ path: '/', method: 'GET' }).reply(429, '', {
headers: { 'retry-after': '3s' },
});
mockPool.intercept({ path: '/', method: 'GET' }).reply(200, '');

const { promise, resolve } = invertedPromise();
const checker = new LinkChecker().on('retry', resolve);
const clock = vi.useFakeTimers({ shouldAdvanceTime: true });
const checkPromise = checker.check({
path: 'test/fixtures/basic',
retry: true,
});
await promise;
await clock.advanceTimersByTime(3000);
const results = await checkPromise;
assert.ok(results.passed);
});

it('should retry 429s with non-standard minutes and seconds format (1m1s)', async () => {
const mockPool = mockAgent.get('http://example.invalid');
mockPool.intercept({ path: '/', method: 'GET' }).reply(429, '', {
headers: { 'retry-after': '1m1s' },
});
mockPool.intercept({ path: '/', method: 'GET' }).reply(200, '');

const { promise, resolve } = invertedPromise();
const checker = new LinkChecker().on('retry', resolve);
const clock = vi.useFakeTimers({ shouldAdvanceTime: true });
const checkPromise = checker.check({
path: 'test/fixtures/basic',
retry: true,
});
await promise;
await clock.advanceTimersByTime(61_000);
const results = await checkPromise;
assert.ok(results.passed);
});

it('should detect requests to wait on the same host', async () => {
const mockPool = mockAgent.get('http://example.invalid');
mockPool.intercept({ path: '/1', method: 'GET' }).reply(429, '', {
Expand Down