Skip to content

Commit c738253

Browse files
authored
Merge pull request #934 from firefoxNX/httpDownload_retry_issue_929
fix: add retry logic for downloads with configurable retries and backoff
2 parents 05824c8 + b90e086 commit c738253

File tree

3 files changed

+162
-71
lines changed

3 files changed

+162
-71
lines changed

docs/api/config-options.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,19 @@ Also see [ARCHIVE_NAME](#archive_name).
269269
Keep in mind that downloaded binaries will never be automatically deleted.
270270
:::
271271

272+
### MAX_RETRIES
273+
274+
| Environment Variable | PackageJson |
275+
| :------------------: | :---------: |
276+
| `MONGOMS_MAX_RETRIES` | `maxRetries` |
277+
278+
Option `MAX_RETRIES` is used to set the maximum number of retry attempts for downloading binaries when a retryable error occurs.
279+
280+
Default: `3`
281+
282+
Set this to control how many times the downloader will attempt to recover from transient errors (like network issues) before failing.
283+
284+
272285
## How to use them in the package.json
273286

274287
To use the config options in the `package.json`, they need to be camelCased (and without `_`), and need to be in the property `config.mongodbMemoryServer`

packages/mongodb-memory-server-core/src/util/MongoBinaryDownload.ts

Lines changed: 147 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import { RequestOptions } from 'https';
1919

2020
const log = debug('MongoMS:MongoBinaryDownload');
2121

22+
const retryableStatusCodes = [503, 500];
23+
const retryableErrorCodes = ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ECONNREFUSED'];
24+
2225
export interface MongoBinaryDownloadProgress {
2326
current: number;
2427
length: number;
@@ -353,126 +356,199 @@ export class MongoBinaryDownload {
353356
}
354357

355358
/**
356-
* Downlaod given httpOptions to tempDownloadLocation, then move it to downloadLocation
359+
* Download given httpOptions to tempDownloadLocation, then move it to downloadLocation
360+
* @param url The URL to download the file from
357361
* @param httpOptions The httpOptions directly passed to https.get
358362
* @param downloadLocation The location the File should be after the download
359363
* @param tempDownloadLocation The location the File should be while downloading
364+
* @param maxRetries Maximum number of retries on download failure
365+
* @param baseDelay Base delay in milliseconds for retrying the download
360366
*/
361367
async httpDownload(
362368
url: URL,
363369
httpOptions: RequestOptions,
364370
downloadLocation: string,
365-
tempDownloadLocation: string
371+
tempDownloadLocation: string,
372+
maxRetries?: number,
373+
baseDelay: number = 1000
366374
): Promise<string> {
367375
log('httpDownload');
368376
const downloadUrl = this.assignDownloadingURL(url);
369377

370-
const maxRedirects = parseInt(resolveConfig(ResolveConfigVariables.MAX_REDIRECTS) || '');
378+
const maxRedirects = parseInt(resolveConfig(ResolveConfigVariables.MAX_REDIRECTS) ?? '');
371379
const useHttpsOptions: Parameters<typeof https.get>[1] = {
372380
maxRedirects: Number.isNaN(maxRedirects) ? 2 : maxRedirects,
373381
...httpOptions,
374382
};
375383

384+
// Get maxRetries from config if not provided
385+
const retriesFromConfig = parseInt(resolveConfig(ResolveConfigVariables.MAX_RETRIES) ?? '');
386+
const retries =
387+
typeof maxRetries === 'number'
388+
? maxRetries
389+
: !Number.isNaN(retriesFromConfig)
390+
? retriesFromConfig
391+
: 3;
392+
393+
for (let attempt = 0; attempt <= retries; attempt++) {
394+
try {
395+
return await this.attemptDownload(
396+
url,
397+
useHttpsOptions,
398+
downloadLocation,
399+
tempDownloadLocation,
400+
downloadUrl,
401+
httpOptions
402+
);
403+
} catch (error: any) {
404+
const shouldRetry =
405+
(error instanceof DownloadError &&
406+
retryableStatusCodes.some((code) => error.message.includes(code.toString()))) ||
407+
(error?.code && retryableErrorCodes.includes(error.code));
408+
409+
if (!shouldRetry || attempt === retries) {
410+
throw error;
411+
}
412+
413+
const base = baseDelay * Math.pow(2, attempt);
414+
const jitter = Math.floor(Math.random() * 1000);
415+
const delay = base + jitter;
416+
log(
417+
`httpDownload: attempt ${attempt + 1} failed with ${error.message}, retrying in ${delay}ms...`
418+
);
419+
await new Promise((resolve) => setTimeout(resolve, delay));
420+
}
421+
}
422+
423+
throw new DownloadError(downloadUrl, 'Max retries exceeded');
424+
}
425+
426+
/**
427+
* Attempt to download the file from the given URL
428+
* This function is used internally by `httpDownload`
429+
* @param url
430+
* @param useHttpsOptions
431+
* @param downloadLocation
432+
* @param tempDownloadLocation
433+
* @param downloadUrl
434+
* @param httpOptions
435+
* @private
436+
*/
437+
private async attemptDownload(
438+
url: URL,
439+
useHttpsOptions: Parameters<typeof https.get>[1],
440+
downloadLocation: string,
441+
tempDownloadLocation: string,
442+
downloadUrl: string,
443+
httpOptions: RequestOptions
444+
): Promise<string> {
376445
return new Promise((resolve, reject) => {
377446
log(`httpDownload: trying to download "${downloadUrl}"`);
378-
https
379-
.get(url, useHttpsOptions, (response) => {
380-
if (response.statusCode != 200) {
381-
if (response.statusCode === 403) {
382-
reject(
383-
new DownloadError(
384-
downloadUrl,
385-
"Status Code is 403 (MongoDB's 404)\n" +
386-
"This means that the requested version-platform combination doesn't exist\n" +
387-
"Try to use different version 'new MongoMemoryServer({ binary: { version: 'X.Y.Z' } })'\n" +
388-
'List of available versions can be found here: ' +
389-
'https://www.mongodb.com/download-center/community/releases/archive'
390-
)
391-
);
392-
393-
return;
394-
}
395447

448+
const request = https.get(url, useHttpsOptions, (response) => {
449+
if (response.statusCode != 200) {
450+
if (response.statusCode === 403) {
396451
reject(
397-
new DownloadError(downloadUrl, `Status Code isnt 200! (it is ${response.statusCode})`)
452+
new DownloadError(
453+
downloadUrl,
454+
"Status Code is 403 (MongoDB's 404)\n" +
455+
"This means that the requested version-platform combination doesn't exist\n" +
456+
"Try to use different version 'new MongoMemoryServer({ binary: { version: 'X.Y.Z' } })'\n" +
457+
'List of available versions can be found here: ' +
458+
'https://www.mongodb.com/download-center/community/releases/archive'
459+
)
398460
);
399461

400462
return;
401463
}
402464

403-
// content-length, otherwise 0
404-
let contentLength: number;
465+
reject(
466+
new DownloadError(downloadUrl, `Status Code isn't 200! (it is ${response.statusCode})`)
467+
);
405468

406-
if (typeof response.headers['content-length'] != 'string') {
407-
log('Response header "content-lenght" is empty!');
469+
return;
470+
}
408471

409-
contentLength = 0;
410-
} else {
411-
contentLength = parseInt(response.headers['content-length'], 10);
472+
// content-length, otherwise 0
473+
let contentLength: number;
412474

413-
if (Number.isNaN(contentLength)) {
414-
log('Response header "content-lenght" resolved to NaN!');
475+
if (typeof response.headers['content-length'] != 'string') {
476+
log('Response header "content-length" is empty!');
477+
contentLength = 0;
478+
} else {
479+
contentLength = parseInt(response.headers['content-length'], 10);
415480

416-
contentLength = 0;
417-
}
481+
if (Number.isNaN(contentLength)) {
482+
log('Response header "content-length" resolved to NaN!');
483+
contentLength = 0;
418484
}
485+
}
486+
487+
// error if the content-length header is missing or is 0 if config option "DOWNLOAD_IGNORE_MISSING_HEADER" is not set to "true"
488+
if (
489+
!envToBool(resolveConfig(ResolveConfigVariables.DOWNLOAD_IGNORE_MISSING_HEADER)) &&
490+
contentLength <= 0
491+
) {
492+
reject(
493+
new DownloadError(
494+
downloadUrl,
495+
'Response header "content-length" does not exist or resolved to NaN'
496+
)
497+
);
498+
499+
return;
500+
}
501+
502+
this.dlProgress.current = 0;
503+
this.dlProgress.length = contentLength;
504+
this.dlProgress.totalMb = Math.round((this.dlProgress.length / 1048576) * 10) / 10;
505+
506+
const fileStream = createWriteStream(tempDownloadLocation);
419507

420-
// error if the content-length header is missing or is 0 if config option "DOWNLOAD_IGNORE_MISSING_HEADER" is not set to "true"
508+
response.pipe(fileStream);
509+
510+
fileStream.on('finish', async () => {
421511
if (
422-
!envToBool(resolveConfig(ResolveConfigVariables.DOWNLOAD_IGNORE_MISSING_HEADER)) &&
423-
contentLength <= 0
512+
this.dlProgress.current < this.dlProgress.length &&
513+
!httpOptions.path?.endsWith('.md5')
424514
) {
425515
reject(
426516
new DownloadError(
427517
downloadUrl,
428-
'Response header "content-length" does not exist or resolved to NaN'
518+
`Too small (${this.dlProgress.current} bytes) mongod binary downloaded.`
429519
)
430520
);
431521

432522
return;
433523
}
434524

435-
this.dlProgress.current = 0;
436-
this.dlProgress.length = contentLength;
437-
this.dlProgress.totalMb = Math.round((this.dlProgress.length / 1048576) * 10) / 10;
438-
439-
const fileStream = createWriteStream(tempDownloadLocation);
440-
441-
response.pipe(fileStream);
442-
443-
fileStream.on('finish', async () => {
444-
if (
445-
this.dlProgress.current < this.dlProgress.length &&
446-
!httpOptions.path?.endsWith('.md5')
447-
) {
448-
reject(
449-
new DownloadError(
450-
downloadUrl,
451-
`Too small (${this.dlProgress.current} bytes) mongod binary downloaded.`
452-
)
453-
);
454-
455-
return;
456-
}
525+
this.printDownloadProgress({ length: 0 }, true);
457526

458-
this.printDownloadProgress({ length: 0 }, true);
527+
fileStream.close();
528+
await fspromises.rename(tempDownloadLocation, downloadLocation);
529+
log(`httpDownload: moved "${tempDownloadLocation}" to "${downloadLocation}"`);
459530

460-
fileStream.close();
461-
await fspromises.rename(tempDownloadLocation, downloadLocation);
462-
log(`httpDownload: moved "${tempDownloadLocation}" to "${downloadLocation}"`);
531+
resolve(downloadLocation);
532+
});
463533

464-
resolve(downloadLocation);
465-
});
534+
response.on('data', (chunk: any) => {
535+
this.printDownloadProgress(chunk);
536+
});
466537

467-
response.on('data', (chunk: any) => {
468-
this.printDownloadProgress(chunk);
469-
});
470-
})
471-
.on('error', (err: Error) => {
472-
// log it without having debug enabled
473-
console.error(`Couldnt download "${downloadUrl}"!`, err.message);
538+
response.on('error', (err: Error) => {
474539
reject(new DownloadError(downloadUrl, err.message));
475540
});
541+
});
542+
543+
request.on('error', (err: Error) => {
544+
console.error(`Could NOT download "${downloadUrl}"!`, err.message);
545+
reject(new DownloadError(downloadUrl, err.message));
546+
});
547+
548+
request.setTimeout(60000, () => {
549+
request.destroy();
550+
reject(new DownloadError(downloadUrl, 'Request timeout after 60 seconds'));
551+
});
476552
});
477553
}
478554

packages/mongodb-memory-server-core/src/util/resolveConfig.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export enum ResolveConfigVariables {
3333
SYSTEM_BINARY_VERSION_CHECK = 'SYSTEM_BINARY_VERSION_CHECK',
3434
USE_ARCHIVE_NAME_FOR_BINARY_NAME = 'USE_ARCHIVE_NAME_FOR_BINARY_NAME',
3535
MAX_REDIRECTS = 'MAX_REDIRECTS',
36+
MAX_RETRIES = 'MAX_RETRIES', // Added for download retry configuration
3637
DISTRO = 'DISTRO',
3738
}
3839

@@ -51,6 +52,7 @@ export const defaultValues = new Map<ResolveConfigVariables, string>([
5152
[ResolveConfigVariables.USE_ARCHIVE_NAME_FOR_BINARY_NAME, 'false'],
5253
[ResolveConfigVariables.MD5_CHECK, 'true'],
5354
[ResolveConfigVariables.MAX_REDIRECTS, '2'],
55+
[ResolveConfigVariables.MAX_RETRIES, '3'], // Default maxRetries for downloads
5456
]);
5557

5658
/** Interface for storing information about the found package.json from `findPackageJson` */

0 commit comments

Comments
 (0)