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