@@ -166,8 +166,79 @@ export class HttpError extends Error {
166166 }
167167}
168168
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+
169234export class HttpClient {
170235
236+ constructor ( private readonly retry : RetryConfig = DEFAULT_RETRY_CONFIG ) {
237+ if ( this . retry ) {
238+ validateRetryConfig ( this . retry ) ;
239+ }
240+ }
241+
171242 /**
172243 * Sends an HTTP request to a remote server. If the server responds with a successful response (2xx), the returned
173244 * 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 {
179250 * header should be explicitly set by the caller. To send a JSON leaf value (e.g. "foo", 5), parse it into JSON,
180251 * and pass as a string or a Buffer along with the appropriate content-type header.
181252 *
182- * @param {HttpRequest } request HTTP request to be sent.
253+ * @param {HttpRequest } config HTTP request to be sent.
183254 * @return {Promise<HttpResponse> } A promise that resolves with the response details.
184255 */
185256 public send ( config : HttpRequestConfig ) : Promise < HttpResponse > {
186257 return this . sendWithRetry ( config ) ;
187258 }
188259
189260 /**
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.
191267 */
192- private sendWithRetry ( config : HttpRequestConfig , attempts : number = 0 ) : Promise < HttpResponse > {
268+ private sendWithRetry ( config : HttpRequestConfig , retryAttempts : number = 0 ) : Promise < HttpResponse > {
193269 return AsyncHttpCall . invoke ( config )
194270 . then ( ( resp ) => {
195271 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+ } ) ;
200279 }
280+
201281 if ( err . response ) {
202282 throw new HttpError ( this . createHttpResponse ( err . response ) ) ;
203283 }
284+
204285 if ( err . code === 'ETIMEDOUT' ) {
205286 throw new FirebaseAppError (
206287 AppErrorCodes . NETWORK_TIMEOUT ,
@@ -218,6 +299,85 @@ export class HttpClient {
218299 }
219300 return new DefaultHttpResponse ( resp ) ;
220301 }
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+ }
221381}
222382
223383/**
0 commit comments