Skip to content

Commit 8f9ca39

Browse files
feat: support non-standard retry-after header formats (#713)
Add support for non-standard retry-after header formats used by CivicPlus and other services that use "30s" or "1m30s" notation instead of the standard seconds or HTTP date format. Changes: - Extract retry-after parsing logic into parseRetryAfter() method - Add regex support for "Xs" and "XmYs" time formats - Add tests for non-standard formats (3s, 1m1s) - Fix missing redirect parameter in 405 and fallback GET requests Closes #556 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <[email protected]>
1 parent 27824d4 commit 8f9ca39

File tree

2 files changed

+68
-7
lines changed

2 files changed

+68
-7
lines changed

src/index.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ export class LinkChecker extends EventEmitter {
297297
response = await makeRequest('GET', options.url.href, {
298298
headers: options.checkOptions.headers,
299299
timeout: options.checkOptions.timeout,
300+
redirect: redirectMode,
300301
});
301302
if (this.shouldRetryAfter(response, options)) {
302303
return;
@@ -321,6 +322,7 @@ export class LinkChecker extends EventEmitter {
321322
response = await makeRequest('GET', options.url.href, {
322323
headers: options.checkOptions.headers,
323324
timeout: options.checkOptions.timeout,
325+
redirect: redirectMode,
324326
});
325327
if (this.shouldRetryAfter(response, options)) {
326328
return;
@@ -554,6 +556,30 @@ export class LinkChecker extends EventEmitter {
554556
}
555557
}
556558

559+
/**
560+
* Parse the retry-after header value into a timestamp.
561+
* Supports standard formats (seconds, HTTP date) and non-standard formats (30s, 1m30s).
562+
* @param retryAfterRaw Raw retry-after header value
563+
* @returns Timestamp in milliseconds when to retry, or NaN if invalid
564+
*/
565+
private parseRetryAfter(retryAfterRaw: string): number {
566+
// Try parsing as seconds
567+
let retryAfter = Number(retryAfterRaw) * 1000 + Date.now();
568+
if (!Number.isNaN(retryAfter)) return retryAfter;
569+
570+
// Try parsing as HTTP date
571+
retryAfter = Date.parse(retryAfterRaw);
572+
if (!Number.isNaN(retryAfter)) return retryAfter;
573+
574+
// Handle non-standard formats like "30s" or "1m30s"
575+
const matches = retryAfterRaw.match(/^(?:(\d+)m)?(\d+)s$/);
576+
if (!matches) return Number.NaN;
577+
578+
return (
579+
(Number(matches[1] || 0) * 60 + Number(matches[2])) * 1000 + Date.now()
580+
);
581+
}
582+
557583
/**
558584
* Check the incoming response for a `retry-after` header. If present,
559585
* and if the status was an HTTP 429, calculate the date at which this
@@ -572,14 +598,9 @@ export class LinkChecker extends EventEmitter {
572598
return false;
573599
}
574600

575-
// The `retry-after` header can come in either <seconds> or
576-
// A specific date to go check.
577-
let retryAfter = Number(retryAfterRaw) * 1000 + Date.now();
601+
const retryAfter = this.parseRetryAfter(retryAfterRaw);
578602
if (Number.isNaN(retryAfter)) {
579-
retryAfter = Date.parse(retryAfterRaw);
580-
if (Number.isNaN(retryAfter)) {
581-
return false;
582-
}
603+
return false;
583604
}
584605

585606
// Check to see if there is already a request to wait for this host

test/test.retry.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,46 @@ describe('retries', () => {
9999
assert.ok(results.passed);
100100
});
101101

102+
it('should retry 429s with non-standard seconds format (3s)', async () => {
103+
const mockPool = mockAgent.get('http://example.invalid');
104+
mockPool.intercept({ path: '/', method: 'GET' }).reply(429, '', {
105+
headers: { 'retry-after': '3s' },
106+
});
107+
mockPool.intercept({ path: '/', method: 'GET' }).reply(200, '');
108+
109+
const { promise, resolve } = invertedPromise();
110+
const checker = new LinkChecker().on('retry', resolve);
111+
const clock = vi.useFakeTimers({ shouldAdvanceTime: true });
112+
const checkPromise = checker.check({
113+
path: 'test/fixtures/basic',
114+
retry: true,
115+
});
116+
await promise;
117+
await clock.advanceTimersByTime(3000);
118+
const results = await checkPromise;
119+
assert.ok(results.passed);
120+
});
121+
122+
it('should retry 429s with non-standard minutes and seconds format (1m1s)', async () => {
123+
const mockPool = mockAgent.get('http://example.invalid');
124+
mockPool.intercept({ path: '/', method: 'GET' }).reply(429, '', {
125+
headers: { 'retry-after': '1m1s' },
126+
});
127+
mockPool.intercept({ path: '/', method: 'GET' }).reply(200, '');
128+
129+
const { promise, resolve } = invertedPromise();
130+
const checker = new LinkChecker().on('retry', resolve);
131+
const clock = vi.useFakeTimers({ shouldAdvanceTime: true });
132+
const checkPromise = checker.check({
133+
path: 'test/fixtures/basic',
134+
retry: true,
135+
});
136+
await promise;
137+
await clock.advanceTimersByTime(61_000);
138+
const results = await checkPromise;
139+
assert.ok(results.passed);
140+
});
141+
102142
it('should detect requests to wait on the same host', async () => {
103143
const mockPool = mockAgent.get('http://example.invalid');
104144
mockPool.intercept({ path: '/1', method: 'GET' }).reply(429, '', {

0 commit comments

Comments
 (0)