UNPKG

got

Version:

Human-friendly and powerful HTTP request library for Node.js

171 lines (170 loc) • 7.18 kB
import net from 'node:net'; const reentry = Symbol('reentry'); const noop = () => { }; export class TimeoutError extends Error { name = 'TimeoutError'; code = 'ETIMEDOUT'; event; constructor(threshold, event) { super(`Timeout awaiting '${event}' for ${threshold}ms`); this.event = event; } } // Node.js turns timer delays above the 32-bit limit (including `Infinity`) into 1 ms with a // warning, so such a delay means "no timeout" instead of an instant timeout. const maximumTimerDelay = 2_147_483_647; /** Whether a delay can be armed as a Node.js timer without collapsing into an instant timeout. */ export const isArmableTimerDelay = (delay) => delay <= maximumTimerDelay && Number.isFinite(delay); export default function timedOut(request, delays, options) { // `reentry` is a symbol, so it cannot collide with a header name, and the request object is // itself the marker. Rewriting either line as an `Object.hasOwn` call would check something else. // eslint-disable-next-line unicorn/no-computed-property-existence-check if (reentry in request) { return noop; } request[reentry] = true; const cancelers = []; const handled = new Set(); // When attaching listeners, it's very easy to forget about them. // Especially if you do error handling and set timeouts. // So instead of checking if it's proper to throw an error on every timeout ever, // track every listener attached through `once` below, so `cancelTimeouts` can remove all of them. const listeners = []; const once = (origin, event, listener) => { origin.once(event, listener); listeners.push({ origin, event, listener }); }; const addTimeout = (delay, event) => { if (!isArmableTimerDelay(delay)) { return noop; } const timeout = setTimeout(timeoutHandler, delay, delay, event); timeout.unref(); const cancel = () => { handled.add(event); clearTimeout(timeout); }; cancelers.push(cancel); return cancel; }; const { host, hostname } = options; const timeoutHandler = (delay, event) => { // Use setTimeout to allow for any cancelled events to be handled first, // to prevent firing any TimeoutError unneeded when the event loop is busy or blocked setTimeout(() => { if (!handled.has(event)) { request.destroy(new TimeoutError(delay, event)); } }, 0); }; const cancelTimeouts = () => { for (const cancel of cancelers) { cancel(); } for (const { origin, event, listener } of listeners) { origin.removeListener(event, listener); } listeners.length = 0; }; request.once('error', error => { cancelTimeouts(); // Save original behavior /* istanbul ignore next */ if (request.listenerCount('error') === 0) { throw error; } }); if (delays.socket !== undefined && isArmableTimerDelay(delays.socket)) { const { socket } = delays; const socketTimeoutHandler = () => { timeoutHandler(socket, 'socket'); }; request.setTimeout(socket, socketTimeoutHandler); // `request.setTimeout(0)` causes a memory leak. // We can just remove the listener and forget about the timer - it's unreffed. // See https://github.com/sindresorhus/got/issues/690 cancelers.push(() => { handled.add('socket'); request.removeListener('timeout', socketTimeoutHandler); }); } const hasLookup = delays.lookup !== undefined; const hasConnect = delays.connect !== undefined; const hasSecureConnect = delays.secureConnect !== undefined; const hasSend = delays.send !== undefined; if (hasLookup || hasConnect || hasSecureConnect || hasSend) { const onSocket = (socket) => { const { socketPath } = request; /* istanbul ignore next: hard to test */ if (socket.connecting) { // WHATWG URL hostnames keep IPv6 brackets, which `net.isIP` rejects. Strip them so IPv6 URLs skip DNS correctly. const normalizedHostname = hostname?.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; const hasPath = Boolean(socketPath ?? (net.isIP(normalizedHostname ?? host ?? '') !== 0)); // A synchronous lookup can finish before the socket is assigned to the request. const hasLookupCompleted = socket.address().address !== undefined; if (hasLookup && !hasPath && !hasLookupCompleted) { const cancelTimeout = addTimeout(delays.lookup, 'lookup'); once(socket, 'lookup', cancelTimeout); } if (hasConnect) { const timeConnect = () => addTimeout(delays.connect, 'connect'); if (hasPath || hasLookupCompleted) { once(socket, 'connect', timeConnect()); } else { once(socket, 'lookup', (error) => { if (error === null) { once(socket, 'connect', timeConnect()); } }); } } if (hasSecureConnect && options.protocol === 'https:') { once(socket, 'connect', () => { const cancelTimeout = addTimeout(delays.secureConnect, 'secureConnect'); once(socket, 'secureConnect', cancelTimeout); }); } } if (hasSend) { const timeRequest = () => addTimeout(delays.send, 'send'); /* istanbul ignore next: hard to test */ if (socket.connecting) { once(socket, 'connect', () => { once(request, 'upload-complete', timeRequest()); }); } else { once(request, 'upload-complete', timeRequest()); } } }; if (request.socket) { onSocket(request.socket); } else { once(request, 'socket', onSocket); } } if (delays.response !== undefined) { let cancelTimeout = noop; const startTimeout = () => { cancelTimeout = addTimeout(delays.response, 'response'); }; once(request, 'upload-complete', startTimeout); once(request, 'response', () => { // A server can respond before the upload finishes. request.removeListener('upload-complete', startTimeout); cancelTimeout(); }); } if (delays.read !== undefined) { once(request, 'response', (response) => { const cancelTimeout = addTimeout(delays.read, 'read'); once(response, 'end', cancelTimeout); }); } return cancelTimeouts; }