@@ -19,6 +19,9 @@ import { RequestOptions } from 'https';
1919
2020const log = debug ( 'MongoMS:MongoBinaryDownload' ) ;
2121
22+ const retryableStatusCodes = [ 503 , 500 ] ;
23+ const retryableErrorCodes = [ 'ECONNRESET' , 'ETIMEDOUT' , 'ENOTFOUND' , 'ECONNREFUSED' ] ;
24+
2225export 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
0 commit comments