123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132 |
- const { Minipass } = require('minipass')
- const fetch = require('minipass-fetch')
- const promiseRetry = require('promise-retry')
- const ssri = require('ssri')
- const { log } = require('proc-log')
- const CachingMinipassPipeline = require('./pipeline.js')
- const { getAgent } = require('@npmcli/agent')
- 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
- // from @npmcli/agent
- 'ECONNECTIONTIMEOUT',
- 'EIDLETIMEOUT',
- 'ERESPONSETIMEOUT',
- 'ETRANSFERTIMEOUT',
- // Known codes we do NOT retry on:
- // ENOTFOUND (getaddrinfo failure. Either bad hostname, or offline)
- // EINVALIDPROXY // invalid protocol from @npmcli/agent
- // EINVALIDRESPONSE // invalid status code from @npmcli/agent
- ]
- 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) => {
- // 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 })
- if (!request.headers.has('connection')) {
- request.headers.set('connection', agent ? 'keep-alive' : 'close')
- }
- if (!request.headers.has('user-agent')) {
- request.headers.set('user-agent', USER_AGENT)
- }
- // 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
- const integrityStream = ssri.integrityStream({
- algorithms: _opts.algorithms,
- integrity: _opts.integrity,
- size: _opts.size,
- })
- 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
- }
- 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) {
- if (typeof options.onRetry === 'function') {
- options.onRetry(res)
- }
- /* eslint-disable-next-line max-len */
- log.http('fetch', `${req.method} ${req.url} attempt ${attemptNum} failed with ${res.status}`)
- 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))
- if (req.method === 'POST' || isRetryError) {
- throw err
- }
- if (typeof options.onRetry === 'function') {
- options.onRetry(err)
- }
- log.http('fetch', `${req.method} ${req.url} attempt ${attemptNum} failed with ${err.code}`)
- return retryHandler(err)
- }
- }, options.retry).catch((err) => {
- // don't reject for http errors, just return them
- if (err.status >= 400 && err.type !== 'system') {
- return err
- }
- throw err
- })
- }
- module.exports = remoteFetch
|