2023-05-07 03:37:34 -07:00
const { Minipass } = require ( 'minipass' )
2021-06-03 20:17:35 +00:00
const fetch = require ( 'minipass-fetch' )
const promiseRetry = require ( 'promise-retry' )
const ssri = require ( 'ssri' )
2024-04-30 23:53:22 -07:00
const { log } = require ( 'proc-log' )
2021-06-03 20:17:35 +00:00
2022-05-25 21:26:36 +00:00
const CachingMinipassPipeline = require ( './pipeline.js' )
2023-09-06 12:54:44 -07:00
const { getAgent } = require ( '@npmcli/agent' )
2021-06-03 20:17:35 +00:00
const pkg = require ( '../package.json' )
const USER _AGENT = ` ${ pkg . name } / ${ pkg . version } (+https://npm.im/ ${ pkg . name } ) `
const RETRY _ERRORS = [
'ECONNRESET' , // remote socket closed on us
'ECONNREFUSED' , // remote host refused to open connection
'EADDRINUSE' , // failed to bind to a local port (proxy?)
'ETIMEDOUT' , // someone in the transaction is WAY TOO SLOW
2023-09-06 12:54:44 -07:00
// from @npmcli/agent
'ECONNECTIONTIMEOUT' ,
'EIDLETIMEOUT' ,
'ERESPONSETIMEOUT' ,
'ETRANSFERTIMEOUT' ,
2021-06-03 20:17:35 +00:00
// Known codes we do NOT retry on:
// ENOTFOUND (getaddrinfo failure. Either bad hostname, or offline)
2023-09-06 12:54:44 -07:00
// EINVALIDPROXY // invalid protocol from @npmcli/agent
// EINVALIDRESPONSE // invalid status code from @npmcli/agent
2021-06-03 20:17:35 +00:00
]
const RETRY _TYPES = [
'request-timeout' ,
]
// make a request directly to the remote source,
// retrying certain classes of errors as well as
// following redirects (through the cache if necessary)
// and verifying response integrity
const remoteFetch = ( request , options ) => {
2024-11-23 23:55:06 -08:00
// options.signal is intended for the fetch itself, not the agent. Attaching it to the agent will re-use that signal across multiple requests, which prevents any connections beyond the first one.
const agent = getAgent ( request . url , { ... options , signal : undefined } )
2022-02-07 22:15:05 +02:00
if ( ! request . headers . has ( 'connection' ) ) {
2021-06-03 20:17:35 +00:00
request . headers . set ( 'connection' , agent ? 'keep-alive' : 'close' )
2022-02-07 22:15:05 +02:00
}
2021-06-03 20:17:35 +00:00
2022-02-07 22:15:05 +02:00
if ( ! request . headers . has ( 'user-agent' ) ) {
2021-06-03 20:17:35 +00:00
request . headers . set ( 'user-agent' , USER _AGENT )
2022-02-07 22:15:05 +02:00
}
2021-06-03 20:17:35 +00:00
// keep our own options since we're overriding the agent
// and the redirect mode
const _opts = {
... options ,
agent ,
redirect : 'manual' ,
}
return promiseRetry ( async ( retryHandler , attemptNum ) => {
const req = new fetch . Request ( request , _opts )
try {
let res = await fetch ( req , _opts )
if ( _opts . integrity && res . status === 200 ) {
// we got a 200 response and the user has specified an expected
// integrity value, so wrap the response in an ssri stream to verify it
2022-06-13 23:04:21 -07:00
const integrityStream = ssri . integrityStream ( {
algorithms : _opts . algorithms ,
integrity : _opts . integrity ,
size : _opts . size ,
} )
2022-05-25 21:26:36 +00:00
const pipeline = new CachingMinipassPipeline ( {
events : [ 'integrity' , 'size' ] ,
} , res . body , integrityStream )
// we also propagate the integrity and size events out to the pipeline so we can use
// this new response body as an integrityEmitter for cacache
integrityStream . on ( 'integrity' , i => pipeline . emit ( 'integrity' , i ) )
integrityStream . on ( 'size' , s => pipeline . emit ( 'size' , s ) )
res = new fetch . Response ( pipeline , res )
// set an explicit flag so we know if our response body will emit integrity and size
res . body . hasIntegrityEmitter = true
2021-06-03 20:17:35 +00:00
}
res . headers . set ( 'x-fetch-attempts' , attemptNum )
// do not retry POST requests, or requests with a streaming body
// do retry requests with a 408, 420, 429 or 500+ status in the response
const isStream = Minipass . isStream ( req . body )
const isRetriable = req . method !== 'POST' &&
! isStream &&
( [ 408 , 420 , 429 ] . includes ( res . status ) || res . status >= 500 )
if ( isRetriable ) {
2022-02-07 22:15:05 +02:00
if ( typeof options . onRetry === 'function' ) {
2021-06-03 20:17:35 +00:00
options . onRetry ( res )
2022-02-07 22:15:05 +02:00
}
2021-06-03 20:17:35 +00:00
2024-04-30 23:53:22 -07:00
/* eslint-disable-next-line max-len */
log . http ( 'fetch' , ` ${ req . method } ${ req . url } attempt ${ attemptNum } failed with ${ res . status } ` )
2021-06-03 20:17:35 +00:00
return retryHandler ( res )
}
return res
} catch ( err ) {
const code = ( err . code === 'EPROMISERETRY' )
? err . retried . code
: err . code
// err.retried will be the thing that was thrown from above
// if it's a response, we just got a bad status code and we
// can re-throw to allow the retry
const isRetryError = err . retried instanceof fetch . Response ||
( RETRY _ERRORS . includes ( code ) && RETRY _TYPES . includes ( err . type ) )
2022-02-07 22:15:05 +02:00
if ( req . method === 'POST' || isRetryError ) {
2021-06-03 20:17:35 +00:00
throw err
2022-02-07 22:15:05 +02:00
}
2021-06-03 20:17:35 +00:00
2022-02-07 22:15:05 +02:00
if ( typeof options . onRetry === 'function' ) {
2021-06-03 20:17:35 +00:00
options . onRetry ( err )
2022-02-07 22:15:05 +02:00
}
2021-06-03 20:17:35 +00:00
2024-04-30 23:53:22 -07:00
log . http ( 'fetch' , ` ${ req . method } ${ req . url } attempt ${ attemptNum } failed with ${ err . code } ` )
2021-06-03 20:17:35 +00:00
return retryHandler ( err )
}
} , options . retry ) . catch ( ( err ) => {
// don't reject for http errors, just return them
2022-02-07 22:15:05 +02:00
if ( err . status >= 400 && err . type !== 'system' ) {
2021-06-03 20:17:35 +00:00
return err
2022-02-07 22:15:05 +02:00
}
2021-06-03 20:17:35 +00:00
throw err
} )
}
module . exports = remoteFetch