1const { Minipass } = require('minipass') 2const fetch = require('minipass-fetch') 3const promiseRetry = require('promise-retry') 4const ssri = require('ssri') 5 6const CachingMinipassPipeline = require('./pipeline.js') 7const { getAgent } = require('@npmcli/agent') 8const pkg = require('../package.json') 9 10const USER_AGENT = `${pkg.name}/${pkg.version} (+https://npm.im/${pkg.name})` 11 12const RETRY_ERRORS = [ 13 'ECONNRESET', // remote socket closed on us 14 'ECONNREFUSED', // remote host refused to open connection 15 'EADDRINUSE', // failed to bind to a local port (proxy?) 16 'ETIMEDOUT', // someone in the transaction is WAY TOO SLOW 17 // from @npmcli/agent 18 'ECONNECTIONTIMEOUT', 19 'EIDLETIMEOUT', 20 'ERESPONSETIMEOUT', 21 'ETRANSFERTIMEOUT', 22 // Known codes we do NOT retry on: 23 // ENOTFOUND (getaddrinfo failure. Either bad hostname, or offline) 24 // EINVALIDPROXY // invalid protocol from @npmcli/agent 25 // EINVALIDRESPONSE // invalid status code from @npmcli/agent 26] 27 28const RETRY_TYPES = [ 29 'request-timeout', 30] 31 32// make a request directly to the remote source, 33// retrying certain classes of errors as well as 34// following redirects (through the cache if necessary) 35// and verifying response integrity 36const remoteFetch = (request, options) => { 37 const agent = getAgent(request.url, options) 38 if (!request.headers.has('connection')) { 39 request.headers.set('connection', agent ? 'keep-alive' : 'close') 40 } 41 42 if (!request.headers.has('user-agent')) { 43 request.headers.set('user-agent', USER_AGENT) 44 } 45 46 // keep our own options since we're overriding the agent 47 // and the redirect mode 48 const _opts = { 49 ...options, 50 agent, 51 redirect: 'manual', 52 } 53 54 return promiseRetry(async (retryHandler, attemptNum) => { 55 const req = new fetch.Request(request, _opts) 56 try { 57 let res = await fetch(req, _opts) 58 if (_opts.integrity && res.status === 200) { 59 // we got a 200 response and the user has specified an expected 60 // integrity value, so wrap the response in an ssri stream to verify it 61 const integrityStream = ssri.integrityStream({ 62 algorithms: _opts.algorithms, 63 integrity: _opts.integrity, 64 size: _opts.size, 65 }) 66 const pipeline = new CachingMinipassPipeline({ 67 events: ['integrity', 'size'], 68 }, res.body, integrityStream) 69 // we also propagate the integrity and size events out to the pipeline so we can use 70 // this new response body as an integrityEmitter for cacache 71 integrityStream.on('integrity', i => pipeline.emit('integrity', i)) 72 integrityStream.on('size', s => pipeline.emit('size', s)) 73 res = new fetch.Response(pipeline, res) 74 // set an explicit flag so we know if our response body will emit integrity and size 75 res.body.hasIntegrityEmitter = true 76 } 77 78 res.headers.set('x-fetch-attempts', attemptNum) 79 80 // do not retry POST requests, or requests with a streaming body 81 // do retry requests with a 408, 420, 429 or 500+ status in the response 82 const isStream = Minipass.isStream(req.body) 83 const isRetriable = req.method !== 'POST' && 84 !isStream && 85 ([408, 420, 429].includes(res.status) || res.status >= 500) 86 87 if (isRetriable) { 88 if (typeof options.onRetry === 'function') { 89 options.onRetry(res) 90 } 91 92 return retryHandler(res) 93 } 94 95 return res 96 } catch (err) { 97 const code = (err.code === 'EPROMISERETRY') 98 ? err.retried.code 99 : err.code 100 101 // err.retried will be the thing that was thrown from above 102 // if it's a response, we just got a bad status code and we 103 // can re-throw to allow the retry 104 const isRetryError = err.retried instanceof fetch.Response || 105 (RETRY_ERRORS.includes(code) && RETRY_TYPES.includes(err.type)) 106 107 if (req.method === 'POST' || isRetryError) { 108 throw err 109 } 110 111 if (typeof options.onRetry === 'function') { 112 options.onRetry(err) 113 } 114 115 return retryHandler(err) 116 } 117 }, options.retry).catch((err) => { 118 // don't reject for http errors, just return them 119 if (err.status >= 400 && err.type !== 'system') { 120 return err 121 } 122 123 throw err 124 }) 125} 126 127module.exports = remoteFetch 128