diff --git a/src/utils/Fetcher.test.ts b/src/utils/Fetcher.test.ts index cb5ea44..5ca8eb5 100644 --- a/src/utils/Fetcher.test.ts +++ b/src/utils/Fetcher.test.ts @@ -190,14 +190,15 @@ describe('fetch', () => { test('converts 429 to custom TooManyRequestsError and blocks consecutive requests', async () => { const mockPool = mockAgent.get('https://some-rate-limited-retry-after-url.test'); - mockPool.intercept({ path: '/' }).reply(429, undefined, { headers: { 'retry-after': '10' } }); + mockPool.intercept({ path: '/' }).reply(429, undefined, { headers: { 'retry-after': '60' } }); + mockPool.intercept({ path: '/' }).reply(200); try { await fetcher.text(ctx, new URL('https://some-rate-limited-retry-after-url.test/')); fail(); } catch (error) { expect(error).toBeInstanceOf(TooManyRequestsError); - expect(error).toMatchObject({ retryAfter: 10 }); + expect(error).toMatchObject({ retryAfter: 60 }); } try { @@ -208,6 +209,15 @@ describe('fetch', () => { } }); + test('retries rate limits with short ttl', async () => { + const mockPool = mockAgent.get('https://some-rate-limited-retry-after-short-url.test'); + mockPool.intercept({ path: '/' }).reply(429, undefined, { headers: { 'retry-after': '1' } }); + mockPool.intercept({ path: '/' }).reply(200); + + const responseText = await fetcher.text(ctx, new URL('https://some-rate-limited-retry-after-short-url.test/')); + expect(responseText).toBe(''); + }); + test('passes through other error as HttpError after retrying 3 times', async () => { const mockPool = mockAgent.get('https://some-error-url.test'); mockPool.intercept({ path: '/' }).reply(500, undefined, { headers: { 'x-foo': 'bar' } }).times(4); diff --git a/src/utils/Fetcher.ts b/src/utils/Fetcher.ts index fa558f4..bd5694b 100644 --- a/src/utils/Fetcher.ts +++ b/src/utils/Fetcher.ts @@ -60,6 +60,7 @@ export class Fetcher { private readonly DEFAULT_QUEUE_TIMEOUT = 5000; private readonly DEFAULT_TIMEOUTS_COUNT_THROW = 30; private readonly TIMEOUT_CACHE_TTL = 3600000; // 1h + private readonly MAX_WAIT_RETRY_AFTER = 10000; private readonly logger: winston.Logger; @@ -262,7 +263,17 @@ export class Fetcher { this.logger.info(`Fetch ${init?.method ?? 'GET'} ${url}`, ctx); const isRateLimitedRaw = await this.rateLimitedCache.get(url.host, { raw: true }); - if (isRateLimitedRaw && isRateLimitedRaw.value) { + /* istanbul ignore if */ + if (isRateLimitedRaw && isRateLimitedRaw.value && isRateLimitedRaw.expires) { + const ttl = isRateLimitedRaw.expires - Date.now(); + if (ttl <= this.MAX_WAIT_RETRY_AFTER && tryCount < 1) { + this.logger.info('Wait out rate limit', ctx); + + await this.sleep(ttl); + + return await this.fetchWithTimeout(ctx, url, { ...init, queueLimit: 1 }, ++tryCount); + } + throw new TooManyRequestsError((isRateLimitedRaw.expires as number - Date.now()) / 1000); } @@ -284,7 +295,7 @@ export class Fetcher { if (error instanceof DOMException && error.name === 'TimeoutError') { if (tryCount < 1) { this.logger.warn(`Retrying fetch ${init?.method ?? 'GET'} ${url} because of timeout`, ctx); - await new Promise(sleep => setTimeout(sleep, 333)); + await this.sleep(333); return await this.fetchWithTimeout(ctx, url, init, ++tryCount); } @@ -298,9 +309,20 @@ export class Fetcher { await this.decreaseTimeoutsCount(url); + if (response.status === 429) { + const retryAfter = parseInt(`${response.headers.get('retry-after')}`) * 1000; + if (retryAfter <= this.MAX_WAIT_RETRY_AFTER && tryCount < 1) { + this.logger.info('Wait out rate limit', ctx); + + await this.sleep(retryAfter); + + return await this.fetchWithTimeout(ctx, url, { ...init, queueLimit: 1 }, ++tryCount); + } + } + if (response.status >= 500 && tryCount < 3) { this.logger.warn(`Retrying fetch ${init?.method ?? 'GET'} ${url} because of error`, ctx); - await new Promise(sleep => setTimeout(sleep, 333)); + await this.sleep(333); return await this.fetchWithTimeout(ctx, url, init, ++tryCount); } @@ -349,4 +371,8 @@ export class Fetcher { release(); } } + + private sleep(ms: number): Promise { + return new Promise(sleep => setTimeout(sleep, ms)); + } }