remote.js 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. const Minipass = require('minipass')
  2. const MinipassPipeline = require('minipass-pipeline')
  3. const fetch = require('minipass-fetch')
  4. const promiseRetry = require('promise-retry')
  5. const ssri = require('ssri')
  6. const getAgent = require('./agent.js')
  7. const pkg = require('../package.json')
  8. const USER_AGENT = `${pkg.name}/${pkg.version} (+https://npm.im/${pkg.name})`
  9. const RETRY_ERRORS = [
  10. 'ECONNRESET', // remote socket closed on us
  11. 'ECONNREFUSED', // remote host refused to open connection
  12. 'EADDRINUSE', // failed to bind to a local port (proxy?)
  13. 'ETIMEDOUT', // someone in the transaction is WAY TOO SLOW
  14. 'ERR_SOCKET_TIMEOUT', // same as above, but this one comes from agentkeepalive
  15. // Known codes we do NOT retry on:
  16. // ENOTFOUND (getaddrinfo failure. Either bad hostname, or offline)
  17. ]
  18. const RETRY_TYPES = [
  19. 'request-timeout',
  20. ]
  21. // make a request directly to the remote source,
  22. // retrying certain classes of errors as well as
  23. // following redirects (through the cache if necessary)
  24. // and verifying response integrity
  25. const remoteFetch = (request, options) => {
  26. const agent = getAgent(request.url, options)
  27. if (!request.headers.has('connection'))
  28. request.headers.set('connection', agent ? 'keep-alive' : 'close')
  29. if (!request.headers.has('user-agent'))
  30. request.headers.set('user-agent', USER_AGENT)
  31. // keep our own options since we're overriding the agent
  32. // and the redirect mode
  33. const _opts = {
  34. ...options,
  35. agent,
  36. redirect: 'manual',
  37. }
  38. return promiseRetry(async (retryHandler, attemptNum) => {
  39. const req = new fetch.Request(request, _opts)
  40. try {
  41. let res = await fetch(req, _opts)
  42. if (_opts.integrity && res.status === 200) {
  43. // we got a 200 response and the user has specified an expected
  44. // integrity value, so wrap the response in an ssri stream to verify it
  45. const integrityStream = ssri.integrityStream({ integrity: _opts.integrity })
  46. res = new fetch.Response(new MinipassPipeline(res.body, integrityStream), res)
  47. }
  48. res.headers.set('x-fetch-attempts', attemptNum)
  49. // do not retry POST requests, or requests with a streaming body
  50. // do retry requests with a 408, 420, 429 or 500+ status in the response
  51. const isStream = Minipass.isStream(req.body)
  52. const isRetriable = req.method !== 'POST' &&
  53. !isStream &&
  54. ([408, 420, 429].includes(res.status) || res.status >= 500)
  55. if (isRetriable) {
  56. if (typeof options.onRetry === 'function')
  57. options.onRetry(res)
  58. return retryHandler(res)
  59. }
  60. return res
  61. } catch (err) {
  62. const code = (err.code === 'EPROMISERETRY')
  63. ? err.retried.code
  64. : err.code
  65. // err.retried will be the thing that was thrown from above
  66. // if it's a response, we just got a bad status code and we
  67. // can re-throw to allow the retry
  68. const isRetryError = err.retried instanceof fetch.Response ||
  69. (RETRY_ERRORS.includes(code) && RETRY_TYPES.includes(err.type))
  70. if (req.method === 'POST' || isRetryError)
  71. throw err
  72. if (typeof options.onRetry === 'function')
  73. options.onRetry(err)
  74. return retryHandler(err)
  75. }
  76. }, options.retry).catch((err) => {
  77. // don't reject for http errors, just return them
  78. if (err.status >= 400 && err.type !== 'system')
  79. return err
  80. throw err
  81. })
  82. }
  83. module.exports = remoteFetch