Skip to content

Commit 4eb8145

Browse files
authored
Implemented exponential backoff with query (#6653)
1 parent 34ad43c commit 4eb8145

File tree

14 files changed

+669
-56
lines changed

14 files changed

+669
-56
lines changed

.changeset/large-lamps-reflect.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@firebase/storage": patch
3+
---
4+
5+
Fixed bug where upload status wasn't being checked after an upload failure.
6+
Implemented exponential backoff and max retry strategy.

common/api-review/storage.api.md

+4-2
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export class _FirebaseStorageImpl implements FirebaseStorage {
8383
// Warning: (ae-forgotten-export) The symbol "Request" needs to be exported by the entry point index.d.ts
8484
//
8585
// (undocumented)
86-
_makeRequest<I extends ConnectionType, O>(requestInfo: RequestInfo_2<I, O>, requestFactory: () => Connection<I>, authToken: string | null, appCheckToken: string | null): Request_2<O>;
86+
_makeRequest<I extends ConnectionType, O>(requestInfo: RequestInfo_2<I, O>, requestFactory: () => Connection<I>, authToken: string | null, appCheckToken: string | null, retry?: boolean): Request_2<O>;
8787
// (undocumented)
8888
makeRequestWithTokens<I extends ConnectionType, O>(requestInfo: RequestInfo_2<I, O>, requestFactory: () => Connection<I>): Promise<O>;
8989
_makeStorageReference(loc: _Location): _Reference;
@@ -319,11 +319,13 @@ export class _UploadTask {
319319
_blob: _FbsBlob;
320320
cancel(): boolean;
321321
catch<T>(onRejected: (p1: StorageError_2) => T | Promise<T>): Promise<T>;
322+
// (undocumented)
323+
isExponentialBackoffExpired(): boolean;
322324
// Warning: (ae-forgotten-export) The symbol "Metadata" needs to be exported by the entry point index.d.ts
323325
_metadata: Metadata | null;
324326
// Warning: (ae-forgotten-export) The symbol "Unsubscribe" needs to be exported by the entry point index.d.ts
325327
// Warning: (ae-forgotten-export) The symbol "Subscribe" needs to be exported by the entry point index.d.ts
326-
on(type: _TaskEvent, nextOrObserver?: StorageObserver<UploadTaskSnapshot> | null | ((snapshot: UploadTaskSnapshot) => unknown), error?: ((a: StorageError_2) => unknown) | null, completed?: Unsubscribe_2 | null): Unsubscribe_2 | Subscribe_2<UploadTaskSnapshot>;
328+
on(type: _TaskEvent, nextOrObserver?: StorageObserver<UploadTaskSnapshot> | null | ((snapshot: UploadTaskSnapshot) => unknown), error?: ((a: StorageError_2) => unknown) | null, completed?: CompleteFn | null): Unsubscribe_2 | Subscribe_2<UploadTaskSnapshot>;
327329
pause(): boolean;
328330
resume(): boolean;
329331
get snapshot(): UploadTaskSnapshot;

packages/storage/src/implementation/backoff.ts

+18-9
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,24 @@ type id = (p1: boolean) => void;
2424
export { id };
2525

2626
/**
27-
* @param f May be invoked
28-
* before the function returns.
29-
* @param callback Get all the arguments passed to the function
30-
* passed to f, including the initial boolean.
27+
* Accepts a callback for an action to perform (`doRequest`),
28+
* and then a callback for when the backoff has completed (`backoffCompleteCb`).
29+
* The callback sent to start requires an argument to call (`onRequestComplete`).
30+
* When `start` calls `doRequest`, it passes a callback for when the request has
31+
* completed, `onRequestComplete`. Based on this, the backoff continues, with
32+
* another call to `doRequest` and the above loop continues until the timeout
33+
* is hit, or a successful response occurs.
34+
* @description
35+
* @param doRequest Callback to perform request
36+
* @param backoffCompleteCb Callback to call when backoff has been completed
3137
*/
3238
export function start(
33-
f: (p1: (success: boolean) => void, canceled: boolean) => void,
39+
doRequest: (
40+
onRequestComplete: (success: boolean) => void,
41+
canceled: boolean
42+
) => void,
3443
// eslint-disable-next-line @typescript-eslint/no-explicit-any
35-
callback: (...args: any[]) => unknown,
44+
backoffCompleteCb: (...args: any[]) => unknown,
3645
timeout: number
3746
): id {
3847
// TODO(andysoto): make this code cleaner (probably refactor into an actual
@@ -55,14 +64,14 @@ export function start(
5564
function triggerCallback(...args: any[]): void {
5665
if (!triggeredCallback) {
5766
triggeredCallback = true;
58-
callback.apply(null, args);
67+
backoffCompleteCb.apply(null, args);
5968
}
6069
}
6170

6271
function callWithDelay(millis: number): void {
6372
retryTimeoutId = setTimeout(() => {
6473
retryTimeoutId = null;
65-
f(handler, canceled());
74+
doRequest(responseHandler, canceled());
6675
}, millis);
6776
}
6877

@@ -72,7 +81,7 @@ export function start(
7281
}
7382
}
7483

75-
function handler(success: boolean, ...args: any[]): void {
84+
function responseHandler(success: boolean, ...args: any[]): void {
7685
if (triggeredCallback) {
7786
clearGlobalTimeout();
7887
return;

packages/storage/src/implementation/constants.ts

+5
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ export const DEFAULT_MAX_OPERATION_RETRY_TIME = 2 * 60 * 1000;
4242
*/
4343
export const DEFAULT_MAX_UPLOAD_RETRY_TIME = 10 * 60 * 1000;
4444

45+
/**
46+
* 1 second
47+
*/
48+
export const DEFAULT_MIN_SLEEP_TIME_MILLIS = 1000;
49+
4550
/**
4651
* This is the value of Number.MIN_SAFE_INTEGER, which is not well supported
4752
* enough for us to use it directly.

packages/storage/src/implementation/error.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ export class StorageError extends FirebaseError {
3434
* @param code - A StorageErrorCode string to be prefixed with 'storage/' and
3535
* added to the end of the message.
3636
* @param message - Error message.
37+
* @param status_ - Corresponding HTTP Status Code
3738
*/
38-
constructor(code: StorageErrorCode, message: string) {
39+
constructor(code: StorageErrorCode, message: string, private status_ = 0) {
3940
super(
4041
prependCode(code),
4142
`Firebase Storage: ${message} (${prependCode(code)})`
@@ -46,6 +47,14 @@ export class StorageError extends FirebaseError {
4647
Object.setPrototypeOf(this, StorageError.prototype);
4748
}
4849

50+
get status(): number {
51+
return this.status_;
52+
}
53+
54+
set status(status: number) {
55+
this.status_ = status;
56+
}
57+
4958
/**
5059
* Compares a StorageErrorCode against this error's code, filtering out the prefix.
5160
*/

packages/storage/src/implementation/request.ts

+21-30
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { ErrorHandler, RequestHandler, RequestInfo } from './requestinfo';
2626
import { isJustDef } from './type';
2727
import { makeQueryString } from './url';
2828
import { Connection, ErrorCode, Headers, ConnectionType } from './connection';
29+
import { isRetryStatusCode } from './utils';
2930

3031
export interface Request<T> {
3132
getPromise(): Promise<T>;
@@ -69,7 +70,8 @@ class NetworkRequest<I extends ConnectionType, O> implements Request<O> {
6970
private errorCallback_: ErrorHandler | null,
7071
private timeout_: number,
7172
private progressCallback_: ((p1: number, p2: number) => void) | null,
72-
private connectionFactory_: () => Connection<I>
73+
private connectionFactory_: () => Connection<I>,
74+
private retry = true
7375
) {
7476
this.promise_ = new Promise((resolve, reject) => {
7577
this.resolve_ = resolve as (value?: O | PromiseLike<O>) => void;
@@ -93,16 +95,15 @@ class NetworkRequest<I extends ConnectionType, O> implements Request<O> {
9395
const connection = this.connectionFactory_();
9496
this.pendingConnection_ = connection;
9597

96-
const progressListener: (progressEvent: ProgressEvent) => void =
97-
progressEvent => {
98-
const loaded = progressEvent.loaded;
99-
const total = progressEvent.lengthComputable
100-
? progressEvent.total
101-
: -1;
102-
if (this.progressCallback_ !== null) {
103-
this.progressCallback_(loaded, total);
104-
}
105-
};
98+
const progressListener: (
99+
progressEvent: ProgressEvent
100+
) => void = progressEvent => {
101+
const loaded = progressEvent.loaded;
102+
const total = progressEvent.lengthComputable ? progressEvent.total : -1;
103+
if (this.progressCallback_ !== null) {
104+
this.progressCallback_(loaded, total);
105+
}
106+
};
106107
if (this.progressCallback_ !== null) {
107108
connection.addUploadProgressListener(progressListener);
108109
}
@@ -118,7 +119,11 @@ class NetworkRequest<I extends ConnectionType, O> implements Request<O> {
118119
this.pendingConnection_ = null;
119120
const hitServer = connection.getErrorCode() === ErrorCode.NO_ERROR;
120121
const status = connection.getStatus();
121-
if (!hitServer || this.isRetryStatusCode_(status)) {
122+
if (
123+
(!hitServer ||
124+
isRetryStatusCode(status, this.additionalRetryCodes_)) &&
125+
this.retry
126+
) {
122127
const wasCanceled = connection.getErrorCode() === ErrorCode.ABORT;
123128
backoffCallback(
124129
false,
@@ -196,22 +201,6 @@ class NetworkRequest<I extends ConnectionType, O> implements Request<O> {
196201
this.pendingConnection_.abort();
197202
}
198203
}
199-
200-
private isRetryStatusCode_(status: number): boolean {
201-
// The codes for which to retry came from this page:
202-
// https://cloud.google.com/storage/docs/exponential-backoff
203-
const isFiveHundredCode = status >= 500 && status < 600;
204-
const extraRetryCodes = [
205-
// Request Timeout: web server didn't receive full request in time.
206-
408,
207-
// Too Many Requests: you're getting rate-limited, basically.
208-
429
209-
];
210-
const isExtraRetryCode = extraRetryCodes.indexOf(status) !== -1;
211-
const isRequestSpecificRetryCode =
212-
this.additionalRetryCodes_.indexOf(status) !== -1;
213-
return isFiveHundredCode || isExtraRetryCode || isRequestSpecificRetryCode;
214-
}
215204
}
216205

217206
/**
@@ -271,7 +260,8 @@ export function makeRequest<I extends ConnectionType, O>(
271260
authToken: string | null,
272261
appCheckToken: string | null,
273262
requestFactory: () => Connection<I>,
274-
firebaseVersion?: string
263+
firebaseVersion?: string,
264+
retry = true
275265
): Request<O> {
276266
const queryPart = makeQueryString(requestInfo.urlParams);
277267
const url = requestInfo.url + queryPart;
@@ -291,6 +281,7 @@ export function makeRequest<I extends ConnectionType, O>(
291281
requestInfo.errorHandler,
292282
requestInfo.timeout,
293283
requestInfo.progressCallback,
294-
requestFactory
284+
requestFactory,
285+
retry
295286
);
296287
}

packages/storage/src/implementation/requests.ts

+10-3
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export function sharedErrorHandler(
104104
xhr: Connection<ConnectionType>,
105105
err: StorageError
106106
): StorageError {
107-
let newErr;
107+
let newErr: StorageError;
108108
if (xhr.getStatus() === 401) {
109109
if (
110110
// This exact message string is the only consistent part of the
@@ -126,6 +126,7 @@ export function sharedErrorHandler(
126126
}
127127
}
128128
}
129+
newErr.status = xhr.getStatus();
129130
newErr.serverResponse = err.serverResponse;
130131
return newErr;
131132
}
@@ -534,8 +535,14 @@ export function continueResumableUpload(
534535
}
535536
const startByte = status_.current;
536537
const endByte = startByte + bytesToUpload;
537-
const uploadCommand =
538-
bytesToUpload === bytesLeft ? 'upload, finalize' : 'upload';
538+
let uploadCommand = '';
539+
if (bytesToUpload === 0) {
540+
uploadCommand = 'finalize';
541+
} else if (bytesLeft === bytesToUpload) {
542+
uploadCommand = 'upload, finalize';
543+
} else {
544+
uploadCommand = 'upload';
545+
}
539546
const headers = {
540547
'X-Goog-Upload-Command': uploadCommand,
541548
'X-Goog-Upload-Offset': `${status_.current}`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* @license
3+
* Copyright 2022 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
/**
19+
* Checks the status code to see if the action should be retried.
20+
*
21+
* @param status Current HTTP status code returned by server.
22+
* @param additionalRetryCodes additional retry codes to check against
23+
*/
24+
export function isRetryStatusCode(
25+
status: number,
26+
additionalRetryCodes: number[]
27+
): boolean {
28+
// The codes for which to retry came from this page:
29+
// https://cloud.google.com/storage/docs/exponential-backoff
30+
const isFiveHundredCode = status >= 500 && status < 600;
31+
const extraRetryCodes = [
32+
// Request Timeout: web server didn't receive full request in time.
33+
408,
34+
// Too Many Requests: you're getting rate-limited, basically.
35+
429
36+
];
37+
const isExtraRetryCode = extraRetryCodes.indexOf(status) !== -1;
38+
const isAdditionalRetryCode = additionalRetryCodes.indexOf(status) !== -1;
39+
return isFiveHundredCode || isExtraRetryCode || isAdditionalRetryCode;
40+
}

packages/storage/src/service.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,8 @@ export class FirebaseStorageImpl implements FirebaseStorage {
302302
requestInfo: RequestInfo<I, O>,
303303
requestFactory: () => Connection<I>,
304304
authToken: string | null,
305-
appCheckToken: string | null
305+
appCheckToken: string | null,
306+
retry = true
306307
): Request<O> {
307308
if (!this._deleted) {
308309
const request = makeRequest(
@@ -311,7 +312,8 @@ export class FirebaseStorageImpl implements FirebaseStorage {
311312
authToken,
312313
appCheckToken,
313314
requestFactory,
314-
this._firebaseVersion
315+
this._firebaseVersion,
316+
retry
315317
);
316318
this._requests.add(request);
317319
// Request removes itself from set when complete.

0 commit comments

Comments
 (0)