马宇豪
2024-07-16 f591c27b57e2418c9495bc02ae8cfff84d35bc18
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
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) => {
  const agent = getAgent(request.url, options)
  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