dd-trace
Version:
Datadog APM tracing client for JavaScript
217 lines (183 loc) • 6.71 kB
JavaScript
const http = require('http')
const https = require('https')
const zlib = require('zlib')
const { storage } = require('../../../../datadog-core')
const log = require('../../log')
const { httpAgent, httpsAgent } = require('../../exporters/common/agents')
const {
RATE_LIMIT_MAX_WAIT_MS,
isRetriableNetworkError,
singleJitteredDelay,
} = require('../../exporters/common/retry')
const { urlToHttpOptions } = require('../../exporters/common/url-to-http-options-polyfill')
const legacyStorage = storage('legacy')
function parseUrl (urlObjOrString) {
if (urlObjOrString !== null && typeof urlObjOrString === 'object') {
return urlToHttpOptions(urlObjOrString)
}
const url = urlToHttpOptions(new URL(urlObjOrString))
if (url.protocol === 'unix:' && url.hostname === '.') {
const udsPath = urlObjOrString.slice(5)
url.path = udsPath
url.pathname = udsPath
}
return url
}
/**
* Simplified HTTP request for test optimization (library config). Uses common HTTP agents.
* Retries: 429 (with X-ratelimit-reset, max 30s wait),
* >=500 and transient network errors (5–7.5s delay with jitter). Max one retry.
* Destroys connections on errors to prevent reuse of bad connections. Preserves
* original status code across retries for telemetry.
*
* Retry timers stay ref'd. Test-runner plugins block the suite via
* `delay: true` channels until this callback fires; an unref'd retry would
* let the host exit first and the suite would never run.
*
* @param {string} data - Request body (e.g. JSON string)
* @param {object} options - { url, path?, method?, headers?, timeout? } (may be mutated)
* @param {Function} callback - (err, res, statusCode) => void
*/
function request (data, options, callback) {
const headers = options.headers ? { ...options.headers } : {}
headers['Content-Length'] = Buffer.byteLength(data, 'utf8')
const opts = { ...options, method: 'POST', headers }
if (opts.url) {
const url = parseUrl(opts.url)
if (url.protocol === 'unix:') {
opts.socketPath = url.pathname
} else {
opts.path = opts.path ?? url.path
opts.protocol = url.protocol
opts.hostname = url.hostname
opts.port = url.port
}
}
const timeout = opts.timeout || 2000
const isSecure = opts.protocol === 'https:'
const client = isSecure ? https : http
if (!opts.socketPath) {
opts.agent = isSecure ? httpsAgent : httpAgent
}
let hasRetried = false
let firstStatusCode = null
const makeRequest = () => {
legacyStorage.run({ noop: true }, () => {
const req = client.request(opts, (res) => {
// Capture non-2xx status code as soon as we see it so telemetry preserves it if the retry
// fails with a network error (no HTTP response) before 'end' fires
if (res.statusCode >= 400 && firstStatusCode === null) {
firstStatusCode = res.statusCode
}
const chunks = []
res.setTimeout(timeout)
res.on('data', chunk => {
chunks.push(chunk)
})
res.once('end', () => {
const buffer = Buffer.concat(chunks)
if (res.statusCode >= 200 && res.statusCode <= 299) {
const isGzip = res.headers['content-encoding'] === 'gzip'
if (isGzip) {
zlib.gunzip(buffer, (err, result) => {
if (err) {
log.error('Could not gunzip response: %s', err.message)
callback(null, '', res.statusCode)
} else {
callback(null, result.toString(), res.statusCode)
}
})
} else {
callback(null, buffer.toString(), res.statusCode)
}
return
}
if (res.statusCode === 429 && !hasRetried) {
const resetHeader = res.headers['x-ratelimit-reset']
const resetTs = (resetHeader === null || resetHeader === undefined)
? Number.NaN
: Number.parseInt(resetHeader, 10)
const waitMs = Number.isFinite(resetTs) ? Math.max(0, resetTs * 1000 - Date.now()) : Number.NaN
if (Number.isFinite(waitMs) && waitMs <= RATE_LIMIT_MAX_WAIT_MS) {
hasRetried = true
setTimeout(makeRequest, waitMs)
return
}
if (!Number.isFinite(waitMs) || waitMs > RATE_LIMIT_MAX_WAIT_MS) {
log.debug('Rate limited (429): drop payload (wait %sms > %sms or invalid header)',
Number.isFinite(waitMs) ? waitMs : 'N/A', RATE_LIMIT_MAX_WAIT_MS)
}
} else if (res.statusCode >= 500 && !hasRetried) {
try {
if (req.socket) req.socket.destroy()
} catch {
// ignore
}
hasRetried = true
setTimeout(makeRequest, singleJitteredDelay())
return
}
const error = buildError(res, buffer, opts)
// Use original status code if this is a failed retry
callback(error, null, firstStatusCode === null ? res.statusCode : firstStatusCode)
})
})
req.once('error', err => {
try {
if (req.socket) req.socket.destroy()
} catch {
// ignore
}
// Retry on retriable network errors
if (!hasRetried && isRetriableNetworkError(err)) {
hasRetried = true
setTimeout(makeRequest, singleJitteredDelay())
return
}
// Pass original status code (if any) for accurate telemetry
callback(err, null, firstStatusCode)
})
req.setTimeout(timeout, () => {
try {
if (typeof req.abort === 'function') {
req.abort()
} else {
req.destroy()
}
} catch {
// ignore
}
})
req.write(data, 'utf8')
req.end()
})
}
makeRequest()
}
/**
* @param {object} res - IncomingMessage
* @param {Buffer} buffer - Response body
* @param {object} options - Request options
* @returns {Error}
*/
function buildError (res, buffer, options) {
let errorMessage = ''
try {
const fullUrl = new URL(
options.path,
options.url || options.hostname || `http://localhost:${options.port}`
).href
errorMessage = `Error from ${fullUrl}: ${res.statusCode} ${http.STATUS_CODES[res.statusCode]}.`
} catch {
// ignore
}
const responseData = buffer.toString()
if (responseData) {
errorMessage += ` Response from the endpoint: "${responseData}"`
}
const error = new log.NoTransmitError(errorMessage)
error.status = res.statusCode
return error
}
module.exports = request