|
1 | 1 | // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
2 | 2 | // See LICENSE in the project root for license information.
|
3 | 3 |
|
4 |
| -import { Colors, IColorableSequence, ITerminal } from '@rushstack/node-core-library'; |
| 4 | +import { Async, Colors, IColorableSequence, ITerminal } from '@rushstack/node-core-library'; |
5 | 5 | import * as crypto from 'crypto';
|
6 | 6 | import * as fetch from 'node-fetch';
|
7 | 7 |
|
@@ -29,9 +29,40 @@ interface IIsoDateString {
|
29 | 29 | dateTime: string;
|
30 | 30 | }
|
31 | 31 |
|
| 32 | +type RetryableRequestResponse<T> = |
| 33 | + | { |
| 34 | + hasNetworkError: true; |
| 35 | + error: Error; |
| 36 | + } |
| 37 | + | { |
| 38 | + hasNetworkError: false; |
| 39 | + response: T; |
| 40 | + }; |
| 41 | + |
32 | 42 | const protocolRegex: RegExp = /^https?:\/\//;
|
33 | 43 | const portRegex: RegExp = /:(\d{1,5})$/;
|
34 | 44 |
|
| 45 | +// Similar to https://docs.microsoft.com/en-us/javascript/api/@azure/storage-blob/storageretrypolicytype?view=azure-node-latest |
| 46 | +enum StorageRetryPolicyType { |
| 47 | + EXPONENTIAL = 0, |
| 48 | + FIXED = 1 |
| 49 | +} |
| 50 | + |
| 51 | +// Similar to https://docs.microsoft.com/en-us/javascript/api/@azure/storage-blob/storageretryoptions?view=azure-node-latest |
| 52 | +interface IStorageRetryOptions { |
| 53 | + maxRetryDelayInMs: number; |
| 54 | + maxTries: number; |
| 55 | + retryDelayInMs: number; |
| 56 | + retryPolicyType: StorageRetryPolicyType; |
| 57 | +} |
| 58 | + |
| 59 | +const storageRetryOptions: IStorageRetryOptions = { |
| 60 | + maxRetryDelayInMs: 120 * 1000, |
| 61 | + maxTries: 4, |
| 62 | + retryDelayInMs: 4 * 1000, |
| 63 | + retryPolicyType: StorageRetryPolicyType.EXPONENTIAL |
| 64 | +}; |
| 65 | + |
35 | 66 | /**
|
36 | 67 | * A helper for reading and updating objects on Amazon S3
|
37 | 68 | *
|
@@ -103,35 +134,81 @@ export class AmazonS3Client {
|
103 | 134 |
|
104 | 135 | public async getObjectAsync(objectName: string): Promise<Buffer | undefined> {
|
105 | 136 | this._writeDebugLine('Reading object from S3');
|
106 |
| - const response: fetch.Response = await this._makeRequestAsync('GET', objectName); |
107 |
| - if (response.ok) { |
108 |
| - return await response.buffer(); |
109 |
| - } else if (response.status === 404) { |
110 |
| - return undefined; |
111 |
| - } else if (response.status === 403 && !this._credentials) { |
112 |
| - return undefined; |
113 |
| - } else { |
114 |
| - this._throwS3Error(response, await this._safeReadResponseText(response)); |
115 |
| - } |
| 137 | + return await this._sendCacheRequestWithRetries(async () => { |
| 138 | + const response: fetch.Response = await this._makeRequestAsync('GET', objectName); |
| 139 | + if (response.ok) { |
| 140 | + return { |
| 141 | + hasNetworkError: false, |
| 142 | + response: await response.buffer() |
| 143 | + }; |
| 144 | + } else if (response.status === 404) { |
| 145 | + return { |
| 146 | + hasNetworkError: false, |
| 147 | + response: undefined |
| 148 | + }; |
| 149 | + } else if ( |
| 150 | + (response.status === 400 || response.status === 401 || response.status === 403) && |
| 151 | + !this._credentials |
| 152 | + ) { |
| 153 | + // unauthorized due to not providing credentials, |
| 154 | + // silence error for better DX when e.g. running locally without credentials |
| 155 | + this._writeWarningLine( |
| 156 | + `No credentials found and received a ${response.status}`, |
| 157 | + ' response code from the cloud storage.', |
| 158 | + ' Maybe run rush update-cloud-credentials', |
| 159 | + ' or set the RUSH_BUILD_CACHE_CREDENTIAL env' |
| 160 | + ); |
| 161 | + return { |
| 162 | + hasNetworkError: false, |
| 163 | + response: undefined |
| 164 | + }; |
| 165 | + } else if (response.status === 400 || response.status === 401 || response.status === 403) { |
| 166 | + throw await this._getS3ErrorAsync(response); |
| 167 | + } else { |
| 168 | + const error: Error = await this._getS3ErrorAsync(response); |
| 169 | + return { |
| 170 | + hasNetworkError: true, |
| 171 | + error |
| 172 | + }; |
| 173 | + } |
| 174 | + }); |
116 | 175 | }
|
117 | 176 |
|
118 | 177 | public async uploadObjectAsync(objectName: string, objectBuffer: Buffer): Promise<void> {
|
119 | 178 | if (!this._credentials) {
|
120 | 179 | throw new Error('Credentials are required to upload objects to S3.');
|
121 | 180 | }
|
122 | 181 |
|
123 |
| - const response: fetch.Response = await this._makeRequestAsync('PUT', objectName, objectBuffer); |
124 |
| - if (!response.ok) { |
125 |
| - this._throwS3Error(response, await this._safeReadResponseText(response)); |
126 |
| - } |
| 182 | + await this._sendCacheRequestWithRetries(async () => { |
| 183 | + const response: fetch.Response = await this._makeRequestAsync('PUT', objectName, objectBuffer); |
| 184 | + if (!response.ok) { |
| 185 | + return { |
| 186 | + hasNetworkError: true, |
| 187 | + error: await this._getS3ErrorAsync(response) |
| 188 | + }; |
| 189 | + } |
| 190 | + return { |
| 191 | + hasNetworkError: false, |
| 192 | + response: undefined |
| 193 | + }; |
| 194 | + }); |
127 | 195 | }
|
128 | 196 |
|
129 | 197 | private _writeDebugLine(...messageParts: (string | IColorableSequence)[]): void {
|
130 | 198 | // if the terminal has been closed then don't bother sending a debug message
|
131 | 199 | try {
|
132 | 200 | this._terminal.writeDebugLine(...messageParts);
|
133 | 201 | } catch (err) {
|
134 |
| - // |
| 202 | + // ignore error |
| 203 | + } |
| 204 | + } |
| 205 | + |
| 206 | + private _writeWarningLine(...messageParts: (string | IColorableSequence)[]): void { |
| 207 | + // if the terminal has been closed then don't bother sending a warning message |
| 208 | + try { |
| 209 | + this._terminal.writeWarningLine(...messageParts); |
| 210 | + } catch (err) { |
| 211 | + // ignore error |
135 | 212 | }
|
136 | 213 | }
|
137 | 214 |
|
@@ -305,8 +382,9 @@ export class AmazonS3Client {
|
305 | 382 | return undefined;
|
306 | 383 | }
|
307 | 384 |
|
308 |
| - private _throwS3Error(response: fetch.Response, text: string | undefined): never { |
309 |
| - throw new Error( |
| 385 | + private async _getS3ErrorAsync(response: fetch.Response): Promise<Error> { |
| 386 | + const text: string | undefined = await this._safeReadResponseText(response); |
| 387 | + return new Error( |
310 | 388 | `Amazon S3 responded with status code ${response.status} (${response.statusText})${
|
311 | 389 | text ? `\n${text}` : ''
|
312 | 390 | }`
|
@@ -371,4 +449,48 @@ export class AmazonS3Client {
|
371 | 449 | );
|
372 | 450 | }
|
373 | 451 | }
|
| 452 | + |
| 453 | + private async _sendCacheRequestWithRetries<T>( |
| 454 | + sendRequest: () => Promise<RetryableRequestResponse<T>> |
| 455 | + ): Promise<T> { |
| 456 | + const response: RetryableRequestResponse<T> = await sendRequest(); |
| 457 | + |
| 458 | + const log: (...messageParts: (string | IColorableSequence)[]) => void = this._writeDebugLine.bind(this); |
| 459 | + |
| 460 | + if (response.hasNetworkError) { |
| 461 | + if (storageRetryOptions && storageRetryOptions.maxTries > 1) { |
| 462 | + log('Network request failed. Will retry request as specified in storageRetryOptions'); |
| 463 | + async function retry(retryAttempt: number): Promise<T> { |
| 464 | + const { retryDelayInMs, retryPolicyType, maxTries, maxRetryDelayInMs } = storageRetryOptions; |
| 465 | + let delay: number = retryDelayInMs; |
| 466 | + if (retryPolicyType === StorageRetryPolicyType.EXPONENTIAL) { |
| 467 | + delay = retryDelayInMs * Math.pow(2, retryAttempt - 1); |
| 468 | + } |
| 469 | + delay = Math.min(maxRetryDelayInMs, delay); |
| 470 | + |
| 471 | + log(`Will retry request in ${delay}s...`); |
| 472 | + await Async.sleep(delay); |
| 473 | + const response: RetryableRequestResponse<T> = await sendRequest(); |
| 474 | + |
| 475 | + if (response.hasNetworkError) { |
| 476 | + if (retryAttempt < maxTries - 1) { |
| 477 | + log('The retried request failed, will try again'); |
| 478 | + return retry(retryAttempt + 1); |
| 479 | + } else { |
| 480 | + log('The retried request failed and has reached the maxTries limit'); |
| 481 | + throw response.error; |
| 482 | + } |
| 483 | + } |
| 484 | + |
| 485 | + return response.response; |
| 486 | + } |
| 487 | + return retry(1); |
| 488 | + } else { |
| 489 | + log('Network request failed and storageRetryOptions is not specified'); |
| 490 | + throw response.error; |
| 491 | + } |
| 492 | + } |
| 493 | + |
| 494 | + return response.response; |
| 495 | + } |
374 | 496 | }
|
0 commit comments