You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
102 lines
3.4 KiB
102 lines
3.4 KiB
const Minipass = require('minipass') |
|
const MinipassPipeline = require('minipass-pipeline') |
|
const fetch = require('minipass-fetch') |
|
const promiseRetry = require('promise-retry') |
|
const ssri = require('ssri') |
|
|
|
const getAgent = require('./agent.js') |
|
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 |
|
'ERR_SOCKET_TIMEOUT', // same as above, but this one comes from agentkeepalive |
|
// Known codes we do NOT retry on: |
|
// ENOTFOUND (getaddrinfo failure. Either bad hostname, or offline) |
|
] |
|
|
|
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({ integrity: _opts.integrity }) |
|
res = new fetch.Response(new MinipassPipeline(res.body, integrityStream), res) |
|
} |
|
|
|
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) |
|
|
|
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) |
|
|
|
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
|
|
|