@@ -166,8 +166,79 @@ export class HttpError extends Error {
166
166
}
167
167
}
168
168
169
+ /**
170
+ * Specifies how failing HTTP requests should be retried.
171
+ */
172
+ export interface RetryConfig {
173
+ /** Maximum number of times to retry a given request. */
174
+ maxRetries : number ;
175
+
176
+ /** HTTP status codes that should be retried. */
177
+ statusCodes ?: number [ ] ;
178
+
179
+ /** Low-level I/O error codes that should be retried. */
180
+ ioErrorCodes ?: string [ ] ;
181
+
182
+ /**
183
+ * The multiplier for exponential back off. The retry delay is calculated in seconds using the formula
184
+ * `(2^n) * backOffFactor`, where n is the number of retries performed so far. When the backOffFactor is set
185
+ * to 0, retries are not delayed. When the backOffFactor is 1, retry duration is doubled each iteration.
186
+ */
187
+ backOffFactor ?: number ;
188
+
189
+ /** Maximum duration to wait before initiating a retry. */
190
+ maxDelayInMillis : number ;
191
+ }
192
+
193
+ /**
194
+ * Default retry configuration for HTTP requests. Retries once on connection reset and timeout errors.
195
+ */
196
+ const DEFAULT_RETRY_CONFIG : RetryConfig = {
197
+ maxRetries : 1 ,
198
+ ioErrorCodes : [ 'ECONNRESET' , 'ETIMEDOUT' ] ,
199
+ maxDelayInMillis : 60 * 1000 ,
200
+ } ;
201
+
202
+ /**
203
+ * Ensures that the given RetryConfig object is valid.
204
+ *
205
+ * @param retry The configuration to be validated.
206
+ */
207
+ function validateRetryConfig ( retry : RetryConfig ) {
208
+ if ( ! validator . isNumber ( retry . maxRetries ) || retry . maxRetries < 0 ) {
209
+ throw new FirebaseAppError (
210
+ AppErrorCodes . INVALID_ARGUMENT , 'maxRetries must be a non-negative integer' ) ;
211
+ }
212
+
213
+ if ( typeof retry . backOffFactor !== 'undefined' ) {
214
+ if ( ! validator . isNumber ( retry . backOffFactor ) || retry . backOffFactor < 0 ) {
215
+ throw new FirebaseAppError (
216
+ AppErrorCodes . INVALID_ARGUMENT , 'backOffFactor must be a non-negative number' ) ;
217
+ }
218
+ }
219
+
220
+ if ( ! validator . isNumber ( retry . maxDelayInMillis ) || retry . maxDelayInMillis < 0 ) {
221
+ throw new FirebaseAppError (
222
+ AppErrorCodes . INVALID_ARGUMENT , 'maxDelayInMillis must be a non-negative integer' ) ;
223
+ }
224
+
225
+ if ( typeof retry . statusCodes !== 'undefined' && ! validator . isArray ( retry . statusCodes ) ) {
226
+ throw new FirebaseAppError ( AppErrorCodes . INVALID_ARGUMENT , 'statusCodes must be an array' ) ;
227
+ }
228
+
229
+ if ( typeof retry . ioErrorCodes !== 'undefined' && ! validator . isArray ( retry . ioErrorCodes ) ) {
230
+ throw new FirebaseAppError ( AppErrorCodes . INVALID_ARGUMENT , 'ioErrorCodes must be an array' ) ;
231
+ }
232
+ }
233
+
169
234
export class HttpClient {
170
235
236
+ constructor ( private readonly retry : RetryConfig = DEFAULT_RETRY_CONFIG ) {
237
+ if ( this . retry ) {
238
+ validateRetryConfig ( this . retry ) ;
239
+ }
240
+ }
241
+
171
242
/**
172
243
* Sends an HTTP request to a remote server. If the server responds with a successful response (2xx), the returned
173
244
* promise resolves with an HttpResponse. If the server responds with an error (3xx, 4xx, 5xx), the promise rejects
@@ -179,28 +250,38 @@ export class HttpClient {
179
250
* header should be explicitly set by the caller. To send a JSON leaf value (e.g. "foo", 5), parse it into JSON,
180
251
* and pass as a string or a Buffer along with the appropriate content-type header.
181
252
*
182
- * @param {HttpRequest } request HTTP request to be sent.
253
+ * @param {HttpRequest } config HTTP request to be sent.
183
254
* @return {Promise<HttpResponse> } A promise that resolves with the response details.
184
255
*/
185
256
public send ( config : HttpRequestConfig ) : Promise < HttpResponse > {
186
257
return this . sendWithRetry ( config ) ;
187
258
}
188
259
189
260
/**
190
- * Sends an HTTP request, and retries it once in case of low-level network errors.
261
+ * Sends an HTTP request. In the event of an error, retries the HTTP request according to the
262
+ * RetryConfig set on the HttpClient.
263
+ *
264
+ * @param {HttpRequestConfig } config HTTP request to be sent.
265
+ * @param {number } retryAttempts Number of retries performed up to now.
266
+ * @return {Promise<HttpResponse> } A promise that resolves with the response details.
191
267
*/
192
- private sendWithRetry ( config : HttpRequestConfig , attempts : number = 0 ) : Promise < HttpResponse > {
268
+ private sendWithRetry ( config : HttpRequestConfig , retryAttempts : number = 0 ) : Promise < HttpResponse > {
193
269
return AsyncHttpCall . invoke ( config )
194
270
. then ( ( resp ) => {
195
271
return this . createHttpResponse ( resp ) ;
196
- } ) . catch ( ( err : LowLevelError ) => {
197
- const retryCodes = [ 'ECONNRESET' , 'ETIMEDOUT' ] ;
198
- if ( retryCodes . indexOf ( err . code ) !== - 1 && attempts === 0 ) {
199
- return this . sendWithRetry ( config , attempts + 1 ) ;
272
+ } )
273
+ . catch ( ( err : LowLevelError ) => {
274
+ const [ delayMillis , canRetry ] = this . getRetryDelayMillis ( retryAttempts , err ) ;
275
+ if ( canRetry && delayMillis <= this . retry . maxDelayInMillis ) {
276
+ return this . waitForRetry ( delayMillis ) . then ( ( ) => {
277
+ return this . sendWithRetry ( config , retryAttempts + 1 ) ;
278
+ } ) ;
200
279
}
280
+
201
281
if ( err . response ) {
202
282
throw new HttpError ( this . createHttpResponse ( err . response ) ) ;
203
283
}
284
+
204
285
if ( err . code === 'ETIMEDOUT' ) {
205
286
throw new FirebaseAppError (
206
287
AppErrorCodes . NETWORK_TIMEOUT ,
@@ -218,6 +299,85 @@ export class HttpClient {
218
299
}
219
300
return new DefaultHttpResponse ( resp ) ;
220
301
}
302
+
303
+ private waitForRetry ( delayMillis : number ) : Promise < void > {
304
+ if ( delayMillis > 0 ) {
305
+ return new Promise ( ( resolve ) => {
306
+ setTimeout ( resolve , delayMillis ) ;
307
+ } ) ;
308
+ }
309
+ return Promise . resolve ( ) ;
310
+ }
311
+
312
+ /**
313
+ * Checks if a failed request is eligible for a retry, and if so returns the duration to wait before initiating
314
+ * the retry.
315
+ *
316
+ * @param {number } retryAttempts Number of retries completed up to now.
317
+ * @param {LowLevelError } err The last encountered error.
318
+ * @returns {[number, boolean] } A 2-tuple where the 1st element is the duration to wait before another retry, and the
319
+ * 2nd element is a boolean indicating whether the request is eligible for a retry or not.
320
+ */
321
+ private getRetryDelayMillis ( retryAttempts : number , err : LowLevelError ) : [ number , boolean ] {
322
+ if ( ! this . isRetryEligible ( retryAttempts , err ) ) {
323
+ return [ 0 , false ] ;
324
+ }
325
+
326
+ const response = err . response ;
327
+ if ( response && response . headers [ 'retry-after' ] ) {
328
+ const delayMillis = this . parseRetryAfterIntoMillis ( response . headers [ 'retry-after' ] ) ;
329
+ if ( delayMillis > 0 ) {
330
+ return [ delayMillis , true ] ;
331
+ }
332
+ }
333
+
334
+ return [ this . backOffDelayMillis ( retryAttempts ) , true ] ;
335
+ }
336
+
337
+ private isRetryEligible ( retryAttempts : number , err : LowLevelError ) : boolean {
338
+ if ( ! this . retry ) {
339
+ return false ;
340
+ }
341
+
342
+ if ( retryAttempts >= this . retry . maxRetries ) {
343
+ return false ;
344
+ }
345
+
346
+ if ( err . response ) {
347
+ const statusCodes = this . retry . statusCodes || [ ] ;
348
+ return statusCodes . indexOf ( err . response . status ) !== - 1 ;
349
+ }
350
+
351
+ const retryCodes = this . retry . ioErrorCodes || [ ] ;
352
+ return retryCodes . indexOf ( err . code ) !== - 1 ;
353
+ }
354
+
355
+ /**
356
+ * Parses the Retry-After HTTP header as a milliseconds value. Return value is negative if the Retry-After header
357
+ * contains an expired timestamp or otherwise malformed.
358
+ */
359
+ private parseRetryAfterIntoMillis ( retryAfter : string ) : number {
360
+ const delaySeconds : number = parseInt ( retryAfter , 10 ) ;
361
+ if ( ! isNaN ( delaySeconds ) ) {
362
+ return delaySeconds * 1000 ;
363
+ }
364
+
365
+ const date = new Date ( retryAfter ) ;
366
+ if ( ! isNaN ( date . getTime ( ) ) ) {
367
+ return date . getTime ( ) - Date . now ( ) ;
368
+ }
369
+ return - 1 ;
370
+ }
371
+
372
+ private backOffDelayMillis ( retryAttempts : number ) : number {
373
+ if ( retryAttempts === 0 ) {
374
+ return 0 ;
375
+ }
376
+
377
+ const backOffFactor = this . retry . backOffFactor || 0 ;
378
+ const delayInSeconds = ( 2 ** retryAttempts ) * backOffFactor ;
379
+ return Math . min ( delayInSeconds * 1000 , this . retry . maxDelayInMillis ) ;
380
+ }
221
381
}
222
382
223
383
/**
0 commit comments