UNPKG

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