@@ -19,6 +19,9 @@ import { RequestOptions } from 'https';
19
19
20
20
const log = debug ( 'MongoMS:MongoBinaryDownload' ) ;
21
21
22
+ const retryableStatusCodes = [ 503 , 500 ] ;
23
+ const retryableErrorCodes = [ 'ECONNRESET' , 'ETIMEDOUT' , 'ENOTFOUND' , 'ECONNREFUSED' ] ;
24
+
22
25
export interface MongoBinaryDownloadProgress {
23
26
current : number ;
24
27
length : number ;
@@ -353,126 +356,199 @@ export class MongoBinaryDownload {
353
356
}
354
357
355
358
/**
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
357
361
* @param httpOptions The httpOptions directly passed to https.get
358
362
* @param downloadLocation The location the File should be after the download
359
363
* @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
360
366
*/
361
367
async httpDownload (
362
368
url : URL ,
363
369
httpOptions : RequestOptions ,
364
370
downloadLocation : string ,
365
- tempDownloadLocation : string
371
+ tempDownloadLocation : string ,
372
+ maxRetries ?: number ,
373
+ baseDelay : number = 1000
366
374
) : Promise < string > {
367
375
log ( 'httpDownload' ) ;
368
376
const downloadUrl = this . assignDownloadingURL ( url ) ;
369
377
370
- const maxRedirects = parseInt ( resolveConfig ( ResolveConfigVariables . MAX_REDIRECTS ) || '' ) ;
378
+ const maxRedirects = parseInt ( resolveConfig ( ResolveConfigVariables . MAX_REDIRECTS ) ?? '' ) ;
371
379
const useHttpsOptions : Parameters < typeof https . get > [ 1 ] = {
372
380
maxRedirects : Number . isNaN ( maxRedirects ) ? 2 : maxRedirects ,
373
381
...httpOptions ,
374
382
} ;
375
383
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 > {
376
445
return new Promise ( ( resolve , reject ) => {
377
446
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
- }
395
447
448
+ const request = https . get ( url , useHttpsOptions , ( response ) => {
449
+ if ( response . statusCode != 200 ) {
450
+ if ( response . statusCode === 403 ) {
396
451
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
+ )
398
460
) ;
399
461
400
462
return ;
401
463
}
402
464
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
+ ) ;
405
468
406
- if ( typeof response . headers [ 'content-length' ] != 'string' ) {
407
- log ( 'Response header "content-lenght" is empty!' ) ;
469
+ return ;
470
+ }
408
471
409
- contentLength = 0 ;
410
- } else {
411
- contentLength = parseInt ( response . headers [ 'content-length' ] , 10 ) ;
472
+ // content-length, otherwise 0
473
+ let contentLength : number ;
412
474
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 ) ;
415
480
416
- contentLength = 0 ;
417
- }
481
+ if ( Number . isNaN ( contentLength ) ) {
482
+ log ( 'Response header "content-length" resolved to NaN!' ) ;
483
+ contentLength = 0 ;
418
484
}
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 ) ;
419
507
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 ( ) => {
421
511
if (
422
- ! envToBool ( resolveConfig ( ResolveConfigVariables . DOWNLOAD_IGNORE_MISSING_HEADER ) ) &&
423
- contentLength <= 0
512
+ this . dlProgress . current < this . dlProgress . length &&
513
+ ! httpOptions . path ?. endsWith ( '.md5' )
424
514
) {
425
515
reject (
426
516
new DownloadError (
427
517
downloadUrl ,
428
- 'Response header "content-length" does not exist or resolved to NaN'
518
+ `Too small ( ${ this . dlProgress . current } bytes) mongod binary downloaded.`
429
519
)
430
520
) ;
431
521
432
522
return ;
433
523
}
434
524
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 ) ;
457
526
458
- this . printDownloadProgress ( { length : 0 } , true ) ;
527
+ fileStream . close ( ) ;
528
+ await fspromises . rename ( tempDownloadLocation , downloadLocation ) ;
529
+ log ( `httpDownload: moved "${ tempDownloadLocation } " to "${ downloadLocation } "` ) ;
459
530
460
- fileStream . close ( ) ;
461
- await fspromises . rename ( tempDownloadLocation , downloadLocation ) ;
462
- log ( `httpDownload: moved "${ tempDownloadLocation } " to "${ downloadLocation } "` ) ;
531
+ resolve ( downloadLocation ) ;
532
+ } ) ;
463
533
464
- resolve ( downloadLocation ) ;
465
- } ) ;
534
+ response . on ( 'data' , ( chunk : any ) => {
535
+ this . printDownloadProgress ( chunk ) ;
536
+ } ) ;
466
537
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 ) => {
474
539
reject ( new DownloadError ( downloadUrl , err . message ) ) ;
475
540
} ) ;
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
+ } ) ;
476
552
} ) ;
477
553
}
478
554
0 commit comments