From c5c68f6ef08c4e6da992f0ad3e8dd864660ad94a Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Mon, 20 Oct 2025 13:25:23 -0700 Subject: [PATCH] feat: support non-standard retry-after header formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Michael Rienstra 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/index.ts | 35 ++++++++++++++++++++++++++++------- test/test.retry.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index f700f33b..be0e67ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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; @@ -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; @@ -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 @@ -572,14 +598,9 @@ export class LinkChecker extends EventEmitter { return false; } - // The `retry-after` header can come in either 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 diff --git a/test/test.retry.ts b/test/test.retry.ts index 496a6c12..833f38b7 100644 --- a/test/test.retry.ts +++ b/test/test.retry.ts @@ -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, '', {