From 6385598cab67bce2a45725864c5eae949c17da6a Mon Sep 17 00:00:00 2001 From: Chad Transtrum Date: Mon, 22 Apr 2024 12:12:51 -0700 Subject: [PATCH] Retry on 429, and handle Retry-After header --- spec/index.spec.ts | 57 ++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 27 ++++++++++++++++++---- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/spec/index.spec.ts b/spec/index.spec.ts index 89f9369..983880e 100644 --- a/spec/index.spec.ts +++ b/spec/index.spec.ts @@ -6,6 +6,7 @@ import axiosRetry, { isSafeRequestError, isIdempotentRequestError, exponentialDelay, + retryAfter, isRetryableError, namespace } from '../src/index'; @@ -859,6 +860,62 @@ describe('exponentialDelay', () => { }); }); +describe('retryAfter', () => { + it('should understand a numeric Retry-After header', () => { + const errorResponse = new AxiosError('Error response'); + errorResponse.response = { status: 429 } as AxiosError['response']; + // @ts-ignore + errorResponse.response.headers = { 'retry-after': '10' }; + const time = retryAfter(errorResponse); + + expect(time).toBe(10000); + }); + + it('should ignore a negative numeric Retry-After header', () => { + const errorResponse = new AxiosError('Error response'); + errorResponse.response = { status: 429 } as AxiosError['response']; + // @ts-ignore + errorResponse.response.headers = { 'retry-after': '-10' }; + const time = retryAfter(errorResponse); + + expect(time).toBe(0); + }); + + it('should understand a date Retry-After header', () => { + const errorResponse = new AxiosError('Error response'); + errorResponse.response = { status: 429 } as AxiosError['response']; + const date = new Date(); + date.setSeconds(date.getSeconds() + 10); + // @ts-ignore + errorResponse.response.headers = { 'retry-after': date.toUTCString() }; + const time = retryAfter(errorResponse); + + expect(time >= 9000 && time <= 10000).toBe(true); + }); + + it('should ignore a past Retry-After header', () => { + const errorResponse = new AxiosError('Error response'); + errorResponse.response = { status: 429 } as AxiosError['response']; + const date = new Date(); + date.setSeconds(date.getSeconds() - 10); + // @ts-ignore + errorResponse.response.headers = { 'retry-after': date.toUTCString() }; + const time = retryAfter(errorResponse); + + expect(time).toBe(0); + }); + + it('should ignore an invalid Retry-After header', () => { + const errorResponse = new AxiosError('Error response'); + errorResponse.response = { status: 429 } as AxiosError['response']; + // @ts-ignore + errorResponse.response.headers = { 'retry-after': 'a couple minutes' }; + const time = retryAfter(errorResponse); + + expect(time).toBe(0); + }); +}); + describe('isRetryableError(error)', () => { it('should be false for aborted requests', () => { const errorResponse = new AxiosError('Error response'); diff --git a/src/index.ts b/src/index.ts index 898ed07..dc70b88 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,7 +102,9 @@ const IDEMPOTENT_HTTP_METHODS = SAFE_HTTP_METHODS.concat(['put', 'delete']); export function isRetryableError(error: AxiosError): boolean { return ( error.code !== 'ECONNABORTED' && - (!error.response || (error.response.status >= 500 && error.response.status <= 599)) + (!error.response || + error.response.status === 429 || + (error.response.status >= 500 && error.response.status <= 599)) ); } @@ -127,16 +129,31 @@ export function isNetworkOrIdempotentRequestError(error: AxiosError): boolean { return isNetworkError(error) || isIdempotentRequestError(error); } -function noDelay() { - return 0; +export function retryAfter(error: AxiosError | undefined = undefined): number { + const retryAfterHeader = error?.response?.headers['retry-after']; + if (!retryAfterHeader) { + return 0; + } + // if the retry after header is a number, convert it to milliseconds + let retryAfterMs = (Number(retryAfterHeader) || 0) * 1000; + // If the retry after header is a date, get the number of milliseconds until that date + if (retryAfterMs === 0) { + retryAfterMs = (new Date(retryAfterHeader).valueOf() || 0) - Date.now(); + } + return Math.max(0, retryAfterMs); +} + +function noDelay(_retryNumber = 0, error: AxiosError | undefined = undefined) { + return Math.max(0, retryAfter(error)); } export function exponentialDelay( retryNumber = 0, - _error: AxiosError | undefined = undefined, + error: AxiosError | undefined = undefined, delayFactor = 100 ): number { - const delay = 2 ** retryNumber * delayFactor; + const calculatedDelay = 2 ** retryNumber * delayFactor; + const delay = Math.max(calculatedDelay, retryAfter(error)); const randomSum = delay * 0.2 * Math.random(); // 0-20% of the delay return delay + randomSum; }