Skip to content

Commit add8656

Browse files
authored
Internal API for retrying HTTP requests (#518)
* Framework for automatic HTTP retries * Added docs and more tests * Updated documentation; Improved a clock-based test using fake timers * Fixed a typo; Updated comment * Trigger builds
1 parent fd064f5 commit add8656

File tree

3 files changed

+614
-8
lines changed

3 files changed

+614
-8
lines changed

src/utils/api-request.ts

Lines changed: 167 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
169234
export 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
/**

src/utils/error.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ export class FirebaseProjectManagementError extends PrefixedFirebaseError {
321321
export class AppErrorCodes {
322322
public static APP_DELETED = 'app-deleted';
323323
public static DUPLICATE_APP = 'duplicate-app';
324+
public static INVALID_ARGUMENT = 'invalid-argument';
324325
public static INTERNAL_ERROR = 'internal-error';
325326
public static INVALID_APP_NAME = 'invalid-app-name';
326327
public static INVALID_APP_OPTIONS = 'invalid-app-options';

0 commit comments

Comments
 (0)