@@ -34,15 +34,20 @@ import { TelemetryHistograms } from "./telemetry/histograms";
3434 * @export
3535 */
3636export const DUMMY_BASE_URL = "https://example.com" ;
37+ // Retry-After header validation: minimum 1 second, maximum 30 minutes (1800 seconds)
38+ const MIN_RETRY_DELAY_MS = 1_000 ; // 1 second
39+ const MAX_RETRY_DELAY_MS = 1_800_000 ; // 30 minutes
40+ // Exponential backoff cap: maximum 120 seconds (2 minutes)
41+ const MAX_EXPONENTIAL_BACKOFF_MS = 120_000 ; // 120 seconds
3742
3843/**
3944 *
4045 * @export
4146 * @interface RequestArgs
4247 */
4348export interface RequestArgs {
44- url : string ;
45- options : any ;
49+ url : string ;
50+ options : any ;
4651}
4752
4853
@@ -79,15 +84,15 @@ export const setSearchParams = function (url: URL, ...objects: any[]) {
7984} ;
8085
8186/**
82- * Check if the given MIME is a JSON MIME.
83- * JSON MIME examples:
84- * application/json
85- * application/json; charset=UTF8
86- * APPLICATION/JSON
87- * application/vnd.company+json
88- * @param mime - MIME (Multipurpose Internet Mail Extensions)
89- * @return True if the given MIME is JSON, false otherwise.
90- */
87+ * Check if the given MIME is a JSON MIME.
88+ * JSON MIME examples:
89+ * application/json
90+ * application/json; charset=UTF8
91+ * APPLICATION/JSON
92+ * application/vnd.company+json
93+ * @param mime - MIME (Multipurpose Internet Mail Extensions)
94+ * @return True if the given MIME is JSON, false otherwise.
95+ */
9196const isJsonMime = ( mime : string ) : boolean => {
9297 // eslint-disable-next-line no-control-regex
9398 const jsonMime = new RegExp ( "^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$" , "i" ) ;
@@ -123,7 +128,7 @@ interface StringIndexable {
123128}
124129
125130export type CallResult < T extends ObjectOrVoid > = T & {
126- $response : AxiosResponse < T >
131+ $response : AxiosResponse < T >
127132} ;
128133
129134export type PromiseResult < T extends ObjectOrVoid > = Promise < CallResult < T > > ;
@@ -136,23 +141,116 @@ export type PromiseResult<T extends ObjectOrVoid> = Promise<CallResult<T>>;
136141function isAxiosError ( err : any ) : boolean {
137142 return err && typeof err === "object" && err . isAxiosError === true ;
138143}
139- function randomTime ( loopCount : number , minWaitInMs : number ) : number {
140- const min = Math . ceil ( 2 ** loopCount * minWaitInMs ) ;
141- const max = Math . ceil ( 2 ** ( loopCount + 1 ) * minWaitInMs ) ;
142- return Math . floor ( Math . random ( ) * ( max - min ) + min ) ; //The maximum is exclusive and the minimum is inclusive
144+ function calculateExponentialBackoffWithJitter ( retryAttempt : number , minWaitInMs : number ) : number {
145+ const minDelayMs = Math . ceil ( 2 ** retryAttempt * minWaitInMs ) ;
146+ const maxDelayMs = Math . ceil ( 2 ** ( retryAttempt + 1 ) * minWaitInMs ) ;
147+ const randomDelayMs = Math . floor ( Math . random ( ) * ( maxDelayMs - minDelayMs ) + minDelayMs ) ;
148+ return Math . min ( randomDelayMs , MAX_EXPONENTIAL_BACKOFF_MS ) ;
149+ }
150+
151+ /**
152+ * Validates if a retry delay is within acceptable bounds
153+ * @param delayMs - Delay in milliseconds
154+ * @returns True if delay is between MIN_RETRY_DELAY_MS and MAX_RETRY_DELAY_MS
155+ */
156+ function isValidRetryDelay ( delayMs : number ) : boolean {
157+ return delayMs >= MIN_RETRY_DELAY_MS && delayMs <= MAX_RETRY_DELAY_MS ;
158+ }
159+
160+ /**
161+ * Parses the Retry-After header and returns delay in milliseconds
162+ * @param headers - HTTP response headers
163+ * @returns Delay in milliseconds if valid, undefined otherwise
164+ */
165+ function parseRetryAfterHeader ( headers : Record < string , string | string [ ] | undefined > ) : number | undefined {
166+ const retryAfterHeader = headers [ "retry-after" ] || headers [ "Retry-After" ] ;
167+
168+ if ( ! retryAfterHeader ) {
169+ return undefined ;
170+ }
171+
172+ const retryAfterHeaderValue = Array . isArray ( retryAfterHeader ) ? retryAfterHeader [ 0 ] : retryAfterHeader ;
173+
174+ if ( ! retryAfterHeaderValue ) {
175+ return undefined ;
176+ }
177+
178+ // Try to parse as integer (seconds)
179+ const retryAfterSeconds = parseInt ( retryAfterHeaderValue , 10 ) ;
180+ if ( ! isNaN ( retryAfterSeconds ) ) {
181+ const retryAfterMs = retryAfterSeconds * 1000 ;
182+ if ( isValidRetryDelay ( retryAfterMs ) ) {
183+ return retryAfterMs ;
184+ }
185+ return undefined ;
186+ }
187+
188+ // Try to parse as HTTP date
189+ try {
190+ const retryAfterDate = new Date ( retryAfterHeaderValue ) ;
191+ const currentDate = new Date ( ) ;
192+ const retryDelayMs = retryAfterDate . getTime ( ) - currentDate . getTime ( ) ;
193+
194+ if ( isValidRetryDelay ( retryDelayMs ) ) {
195+ return retryDelayMs ;
196+ }
197+ } catch ( e ) {
198+ // Invalid date format
199+ }
200+
201+ return undefined ;
143202}
144203
145204interface WrappedAxiosResponse < R > {
146205 response ?: AxiosResponse < R > ;
147206 retries : number ;
148207}
149208
209+ function checkIfRetryableError (
210+ err : any ,
211+ iterationCount : number ,
212+ maxRetry : number
213+ ) : { retryable : boolean ; error ?: Error } {
214+ if ( ! isAxiosError ( err ) ) {
215+ return { retryable : false , error : new FgaError ( err ) } ;
216+ }
217+
218+ const status = ( err as any ) ?. response ?. status ;
219+ const isNetworkError = ! status ;
220+
221+ if ( isNetworkError ) {
222+ if ( iterationCount > maxRetry ) {
223+ return { retryable : false , error : new FgaError ( err ) } ;
224+ }
225+ return { retryable : true } ;
226+ }
227+
228+ if ( status === 400 || status === 422 ) {
229+ return { retryable : false , error : new FgaApiValidationError ( err ) } ;
230+ } else if ( status === 401 || status === 403 ) {
231+ return { retryable : false , error : new FgaApiAuthenticationError ( err ) } ;
232+ } else if ( status === 404 ) {
233+ return { retryable : false , error : new FgaApiNotFoundError ( err ) } ;
234+ } else if ( status === 429 || ( status >= 500 && status !== 501 ) ) {
235+ if ( iterationCount > maxRetry ) {
236+ if ( status === 429 ) {
237+ return { retryable : false , error : new FgaApiRateLimitExceededError ( err ) } ;
238+ } else {
239+ return { retryable : false , error : new FgaApiInternalError ( err ) } ;
240+ }
241+ }
242+ return { retryable : true } ;
243+ } else {
244+ return { retryable : false , error : new FgaApiError ( err ) } ;
245+ }
246+ }
247+
150248export async function attemptHttpRequest < B , R > (
151249 request : AxiosRequestConfig < B > ,
152250 config : {
153- maxRetry : number ;
154- minWaitInMs : number ;
155- } ,
251+ maxRetry : number ;
252+ minWaitInMs : number ;
253+ } ,
156254 axiosInstance : AxiosInstance ,
157255) : Promise < WrappedAxiosResponse < R > | undefined > {
158256 let iterationCount = 0 ;
@@ -165,32 +263,27 @@ export async function attemptHttpRequest<B, R>(
165263 retries : iterationCount - 1 ,
166264 } ;
167265 } catch ( err : any ) {
168- if ( ! isAxiosError ( err ) ) {
169- throw new FgaError ( err ) ;
266+ const { retryable, error } = checkIfRetryableError ( err , iterationCount , config . maxRetry ) ;
267+
268+ if ( ! retryable ) {
269+ throw error ;
170270 }
271+
171272 const status = ( err as any ) ?. response ?. status ;
172- if ( status === 400 || status === 422 ) {
173- throw new FgaApiValidationError ( err ) ;
174- } else if ( status === 401 || status === 403 ) {
175- throw new FgaApiAuthenticationError ( err ) ;
176- } else if ( status === 404 ) {
177- throw new FgaApiNotFoundError ( err ) ;
178- } else if ( status === 429 || status >= 500 ) {
179- if ( iterationCount >= config . maxRetry ) {
180- // We have reached the max retry limit
181- // Thus, we have no choice but to throw
182- if ( status === 429 ) {
183- throw new FgaApiRateLimitExceededError ( err ) ;
184- } else {
185- throw new FgaApiInternalError ( err ) ;
186- }
187- }
188- await new Promise ( r => setTimeout ( r , randomTime ( iterationCount , config . minWaitInMs ) ) ) ;
189- } else {
190- throw new FgaApiError ( err ) ;
273+ let retryDelayMs : number | undefined ;
274+
275+ if ( ( status &&
276+ ( status === 429 || ( status >= 500 && status !== 501 ) ) ) &&
277+ err . response ?. headers ) {
278+ retryDelayMs = parseRetryAfterHeader ( err . response . headers ) ;
279+ }
280+ if ( ! retryDelayMs ) {
281+ retryDelayMs = calculateExponentialBackoffWithJitter ( iterationCount , config . minWaitInMs ) ;
191282 }
283+
284+ await new Promise ( r => setTimeout ( r , Math . min ( retryDelayMs , MAX_RETRY_DELAY_MS ) ) ) ;
192285 }
193- } while ( iterationCount < config . maxRetry + 1 ) ;
286+ } while ( iterationCount < config . maxRetry + 1 ) ;
194287}
195288
196289/**
@@ -263,4 +356,4 @@ export const createRequestFunction = function (axiosArgs: RequestArgs, axiosInst
263356
264357 return result ;
265358 } ;
266- } ;
359+ } ;
0 commit comments