got
Version:
Human-friendly and powerful HTTP request library for Node.js
171 lines (170 loc) • 7.18 kB
JavaScript
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;
}