Repository: sindresorhus/got
Pull request #534 (2018-07-23 21:30:43)

Customize timeouts and generally improve the whole thing
- Refactor timed-out to optimistically defer errors until after the poll
  phase of the current event loop tick.
- Timeouts begin and end when their respective phase of the lifecycle
  begins and ends.
- Timeouts result in a `got.TimeoutError`

This includes the controversial  change discussed in #525: removal of `setImmediate` around response handling and the addition of `setImmediate` in the timeout handling. Those change are not strictly required and can be reverted if necessary and everything in this PR will still work.

jstewmon: @szmarczak thanks for the flattering review :-)

Patch:
diff --git a/advanced-creation.md b/advanced-creation.md
index 59b14c9..b7a8993 100644
--- a/advanced-creation.md
+++ b/advanced-creation.md
@@ -11,7 +11,8 @@ Configure a new `got` instance with the provided settings.<br>
 
 ##### [options](readme.md#options)
 
-To inherit from parent, set it as `got.defaults.options` or use [object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#Spread_in_object_literals).
+To inherit from parent, set it as `got.defaults.options` or use [`got.assignOptions(defaults.options, options)`](readme.md#gotassignoptionsparentoptions-newoptions).<br>
+**Note**: Avoid using [object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#Spread_in_object_literals) as it doesn't work recursively.
 
 ##### methods
 
@@ -31,32 +32,31 @@ Function making additional changes to the request.
 To inherit from parent, set it as `got.defaults.handler`.<br>
 To use the default handler, just omit specifying this.
 
-###### [url](readme.md#url)
-
 ###### [options](readme.md#options)
 
+**Note:** These options are [normalized](source/normalize-arguments.js).
+
 ###### next()
 
 Normalizes arguments and returns a `Promise` or a `Stream` depending on [`options.stream`](readme.md#stream).
 
 ```js
 const settings = {
-	handler: (url, options, next) => {
+	handler: (options, next) => {
 		if (options.stream) {
 			// It's a Stream
 			// We can perform stream-specific actions on it
-			return next(url, options)
+			return next(options)
 				.on('request', request => setTimeout(() => request.abort(), 50));
 		}
 
 		// It's a Promise
-		return next(url, options);
+		return next(options);
 	},
 	methods: got.defaults.methods,
-	options: {
-		...got.defaults.options,
+	options: got.assignOptions(got.defaults.options, {
 		json: true
-	}
+	})
 };
 
 const jsonGot = got.create(settings);
@@ -64,9 +64,7 @@ const jsonGot = got.create(settings);
 
 ```js
 const defaults = {
-	handler: (url, options, next) => {
-		return next(url, options);
-	},
+	handler: (options, next) => next(options),
 	methods: [
 		'get',
 		'post',
@@ -101,12 +99,11 @@ const unchangedGot = got.create(defaults);
 const settings = {
 	handler: got.defaults.handler,
 	methods: got.defaults.methods,
-	options: {
-		...got.defaults.options,
+	options: got.assignOptions(got.defaults.options, {
 		headers: {
 			unicorn: 'rainbow'
 		}
-	}
+	})
 };
 
 const unicorn = got.create(settings);
diff --git a/readme.md b/readme.md
index 5e20aaf..ee3058e 100644
--- a/readme.md
+++ b/readme.md
@@ -188,7 +188,14 @@ Type: `number` `Object`
 
 Milliseconds to wait for the server to end the response before aborting request with `ETIMEDOUT` error (a.k.a. `request` property). By default there's no timeout.
 
-This also accepts an object with separate `connect`, `socket`, and `request` fields for connection, socket, and entire request timeouts.
+This also accepts an object with separate `lookup`, `connect`, `socket`, `response` and `request` fields to specify granular timeouts for each phase of the request.
+
+- `lookup` starts when a socket is assigned and ends when the hostname has been resolved. Does not apply when using a Unix domain socket.
+- `connect` starts when `lookup` completes (or when the socket is assigned if lookup does not apply to the request) and ends when the socket is connected.
+- `socket` starts when the socket is connected. See [request.setTimeout](https://nodejs.org/api/http.html#http_request_settimeout_timeout_callback).
+- `response` starts when the request has been written to the socket and ends when the response headers are received.
+- `send` starts when the socket is connected and ends with the request has been written to the socket.
+- `request` starts when the request is initiated and ends when the response's end event fires.
 
 ###### retry
 
@@ -398,6 +405,18 @@ client.get('/demo');
 
 *Need more control over the behavior of Got? Check out the [`got.create()`](advanced-creation.md).*
 
+#### got.assignOptions(parentOptions, newOptions)
+
+Extends parent options. Avoid using [object spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#Spread_in_object_literals) as it doesn't work recursively:
+
+```js
+const a = {headers: {cat: 'meow'}};
+const b = {headers: {dog: 'woof'}};
+ 
+{...a, ...b}            // => {headers: {dog: 'woof'}}
+got.assignOptions(a, b) // => {headers: {cat: 'meow', dog: 'woof'}}
+```
+
 ## Errors
 
 Each error contains (if available) `statusCode`, `statusMessage`, `host`, `hostname`, `method`, `path`, `protocol` and `url` properties to make debugging easier.
diff --git a/source/as-promise.js b/source/as-promise.js
index fcd261d..1b4ff00 100644
--- a/source/as-promise.js
+++ b/source/as-promise.js
@@ -24,17 +24,22 @@ module.exports = options => {
 
 			proxy.emit('request', req);
 
+			const uploadComplete = () => {
+				req.emit('upload-complete');
+			};
+
 			onCancel(() => {
 				req.abort();
 			});
 
 			if (is.nodeStream(options.body)) {
+				options.body.once('end', uploadComplete);
 				options.body.pipe(req);
 				options.body = undefined;
 				return;
 			}
 
-			req.end(options.body);
+			req.end(options.body, uploadComplete);
 		});
 
 		emitter.on('response', async response => {
diff --git a/source/as-stream.js b/source/as-stream.js
index 35f14c5..501da2b 100644
--- a/source/as-stream.js
+++ b/source/as-stream.js
@@ -28,23 +28,28 @@ module.exports = options => {
 
 	emitter.on('request', req => {
 		proxy.emit('request', req);
+		const uploadComplete = () => {
+			req.emit('upload-complete');
+		};
 
 		if (is.nodeStream(options.body)) {
+			options.body.once('end', uploadComplete);
 			options.body.pipe(req);
 			return;
 		}
 
 		if (options.body) {
-			req.end(options.body);
+			req.end(options.body, uploadComplete);
 			return;
 		}
 
 		if (options.method === 'POST' || options.method === 'PUT' || options.method === 'PATCH') {
+			input.once('end', uploadComplete);
 			input.pipe(req);
 			return;
 		}
 
-		req.end();
+		req.end(uploadComplete);
 	});
 
 	emitter.on('response', response => {
diff --git a/source/create.js b/source/create.js
index 55e173a..a649373 100644
--- a/source/create.js
+++ b/source/create.js
@@ -1,5 +1,4 @@
 'use strict';
-const URLGlobal = typeof URL === 'undefined' ? require('url').URL : URL; // TODO: Use the `URL` global when targeting Node.js 10
 const errors = require('./errors');
 const assignOptions = require('./assign-options');
 const asStream = require('./as-stream');
@@ -7,15 +6,7 @@ const asPromise = require('./as-promise');
 const normalizeArguments = require('./normalize-arguments');
 const deepFreeze = require('./deep-freeze');
 
-const makeNext = defaults => (path, options) => {
-	let url = path;
-
-	if (options.baseUrl) {
-		url = new URLGlobal(path, options.baseUrl);
-	}
-
-	options = normalizeArguments(url, options, defaults);
-
+const next = options => {
 	if (options.stream) {
 		return asStream(options);
 	}
@@ -24,15 +15,14 @@ const makeNext = defaults => (path, options) => {
 };
 
 const create = defaults => {
-	const next = makeNext(defaults);
 	if (!defaults.handler) {
-		defaults.handler = next;
+		defaults.handler = (options, next) => next(options);
 	}
 
 	function got(url, options) {
 		try {
 			options = assignOptions(defaults.options, options);
-			return defaults.handler(url, options, next);
+			return defaults.handler(normalizeArguments(url, options, defaults), next);
 		} catch (error) {
 			return Promise.reject(error);
 		}
@@ -48,7 +38,7 @@ const create = defaults => {
 	got.stream = (url, options) => {
 		options = assignOptions(defaults.options, options);
 		options.stream = true;
-		return defaults.handler(url, options, next);
+		return defaults.handler(normalizeArguments(url, options, defaults), next);
 	};
 
 	for (const method of defaults.methods) {
@@ -56,7 +46,7 @@ const create = defaults => {
 		got.stream[method] = (url, options) => got.stream(url, {...options, method});
 	}
 
-	Object.assign(got, errors);
+	Object.assign(got, {...errors, assignOptions});
 	Object.defineProperty(got, 'defaults', {
 		value: deepFreeze(defaults),
 		writable: false,
diff --git a/source/errors.js b/source/errors.js
index ad83388..dff0576 100644
--- a/source/errors.js
+++ b/source/errors.js
@@ -19,6 +19,7 @@ class GotError extends Error {
 			hostname: opts.hostname,
 			method: opts.method,
 			path: opts.path,
+			socketPath: opts.socketPath,
 			protocol: opts.protocol,
 			url: opts.href
 		});
@@ -89,4 +90,12 @@ module.exports.UnsupportedProtocolError = class extends GotError {
 	}
 };
 
+module.exports.TimeoutError = class extends GotError {
+	constructor(threshold, event, opts) {
+		super(`Timeout awaiting '${event}' for ${threshold}ms`, {code: 'ETIMEDOUT'}, opts);
+		this.name = 'TimeoutError';
+		this.event = event;
+	}
+};
+
 module.exports.CancelError = PCancelable.CancelError;
diff --git a/source/normalize-arguments.js b/source/normalize-arguments.js
index e09e97d..d427da9 100644
--- a/source/normalize-arguments.js
+++ b/source/normalize-arguments.js
@@ -1,5 +1,6 @@
 'use strict';
-const URLSearchParamsGlobal = typeof URLSearchParams === 'undefined' ? require('url').URLSearchParams : URLSearchParams; // TODO: Use the `URL` global when targeting Node.js 10
+const URLGlobal = typeof URL === 'undefined' ? require('url').URL : URL; // TODO: Use the `URL` global when targeting Node.js 10
+const URLSearchParamsGlobal = typeof URLSearchParams === 'undefined' ? require('url').URLSearchParams : URLSearchParams;
 const is = require('@sindresorhus/is');
 const toReadableStream = require('to-readable-stream');
 const urlParseLax = require('url-parse-lax');
@@ -17,18 +18,24 @@ module.exports = (url, options, defaults) => {
 
 	if (!is.string(url) && !is.object(url)) {
 		throw new TypeError(`Parameter \`url\` must be a string or object, not ${is(url)}`);
-	} else if (is.string(url)) {
-		url = url.replace(/^unix:/, 'http://$&');
+	}
 
-		try {
-			decodeURI(url);
-		} catch (_) {
-			throw new Error('Parameter `url` must contain valid UTF-8 character sequences');
-		}
+	if (is.string(url)) {
+		if (options.baseUrl) {
+			url = urlToOptions(new URLGlobal(url, options.baseUrl));
+		} else {
+			url = url.replace(/^unix:/, 'http://$&');
 
-		url = urlParseLax(url);
-		if (url.auth) {
-			throw new Error('Basic authentication must be done with the `auth` option');
+			try {
+				decodeURI(url);
+			} catch (_) {
+				throw new Error('Parameter `url` must contain valid UTF-8 character sequences');
+			}
+
+			url = urlParseLax(url);
+			if (url.auth) {
+				throw new Error('Basic authentication must be done with the `auth` option');
+			}
 		}
 	} else if (is(url) === 'URL') {
 		url = urlToOptions(url);
@@ -36,6 +43,7 @@ module.exports = (url, options, defaults) => {
 
 	options = {
 		path: '',
+		headers: {},
 		...url,
 		protocol: url.protocol || 'http:', // Override both null/undefined with default protocol
 		...options
diff --git a/source/request-as-event-emitter.js b/source/request-as-event-emitter.js
index 0f9f3bc..7631b53 100644
--- a/source/request-as-event-emitter.js
+++ b/source/request-as-event-emitter.js
@@ -10,7 +10,7 @@ const timedOut = require('./timed-out');
 const getBodySize = require('./get-body-size');
 const getResponse = require('./get-response');
 const progress = require('./progress');
-const {CacheError, UnsupportedProtocolError, MaxRedirectsError, RequestError} = require('./errors');
+const {GotError, CacheError, UnsupportedProtocolError, MaxRedirectsError, RequestError} = require('./errors');
 
 const getMethodRedirectCodes = new Set([300, 301, 302, 303, 304, 305, 307, 308]);
 const allMethodRedirectCodes = new Set([300, 303, 307, 308]);
@@ -91,13 +91,11 @@ module.exports = (options = {}) => {
 				return;
 			}
 
-			setImmediate(() => {
-				try {
-					getResponse(response, options, emitter, redirects);
-				} catch (error) {
-					emitter.emit('error', error);
-				}
-			});
+			try {
+				getResponse(response, options, emitter, redirects);
+			} catch (error) {
+				emitter.emit('error', error);
+			}
 		});
 
 		cacheReq.on('error', error => {
@@ -119,10 +117,12 @@ module.exports = (options = {}) => {
 					return;
 				}
 
-				const err = new RequestError(error, options);
-				emitter.emit('retry', err, retried => {
+				if (!(error instanceof GotError)) {
+					error = new RequestError(error, options);
+				}
+				emitter.emit('retry', error, retried => {
 					if (!retried) {
-						emitter.emit('error', err);
+						emitter.emit('error', error);
 					}
 				});
 			});
@@ -130,12 +130,10 @@ module.exports = (options = {}) => {
 			progress.upload(req, emitter, uploadBodySize);
 
 			if (options.gotTimeout) {
-				timedOut(req, options.gotTimeout);
+				timedOut(req, options);
 			}
 
-			setImmediate(() => {
-				emitter.emit('request', req);
-			});
+			emitter.emit('request', req);
 		});
 	};
 
diff --git a/source/timed-out.js b/source/timed-out.js
index 0c43bf1..aeb5134 100644
--- a/source/timed-out.js
+++ b/source/timed-out.js
@@ -1,79 +1,131 @@
 'use strict';
+const net = require('net');
+const {TimeoutError} = require('./errors');
 
-// Forked from https://github.com/floatdrop/timed-out
+const reentry = Symbol('reentry');
 
-module.exports = function (req, delays) {
-	if (req.timeoutTimer) {
-		return req;
+function addTimeout(delay, callback, ...args) {
+	// Event loop order is timers, poll, immediates.
+	// The timed event may emit during the current tick poll phase, so
+	// defer calling the handler until the poll phase completes.
+	let immediate;
+	const timeout = setTimeout(
+		() => {
+			immediate = setImmediate(callback, delay, ...args);
+			if (immediate.unref) {
+				// Added in node v9.7.0
+				immediate.unref();
+			}
+		},
+		delay
+	);
+	timeout.unref();
+	return () => {
+		clearTimeout(timeout);
+		clearImmediate(immediate);
+	};
+}
+
+module.exports = function (req, options) {
+	if (req[reentry]) {
+		return;
 	}
+	req[reentry] = true;
+	const {gotTimeout: delays, host, hostname} = options;
+	const timeoutHandler = (delay, event) => {
+		req.abort();
+		req.emit('error', new TimeoutError(delay, event, options));
+	};
+	const cancelers = [];
+	const cancelTimeouts = () => {
+		cancelers.forEach(cancelTimeout => cancelTimeout());
+	};
 
-	const host = req._headers ? (' to ' + req._headers.host) : '';
+	req.on('error', cancelTimeouts);
+	req.once('response', response => {
+		response.once('end', cancelTimeouts);
+	});
 
-	function throwESOCKETTIMEDOUT() {
-		req.abort();
-		const e = new Error('Socket timed out on request' + host);
-		e.code = 'ESOCKETTIMEDOUT';
-		req.emit('error', e);
+	if (delays.request !== undefined) {
+		const cancelTimeout = addTimeout(
+			delays.request,
+			timeoutHandler,
+			'request'
+		);
+		cancelers.push(cancelTimeout);
 	}
-
-	function throwETIMEDOUT() {
-		req.abort();
-		const e = new Error('Connection timed out on request' + host);
-		e.code = 'ETIMEDOUT';
-		req.emit('error', e);
+	if (delays.socket !== undefined) {
+		req.setTimeout(
+			delays.socket,
+			() => {
+				timeoutHandler(delays.socket, 'socket');
+			}
+		);
+	}
+	if (delays.lookup !== undefined && !req.socketPath && !net.isIP(hostname || host)) {
+		req.once('socket', socket => {
+			if (socket.connecting) {
+				const cancelTimeout = addTimeout(
+					delays.lookup,
+					timeoutHandler,
+					'lookup'
+				);
+				cancelers.push(cancelTimeout);
+				socket.once('lookup', cancelTimeout);
+			}
+		});
 	}
-
 	if (delays.connect !== undefined) {
-		req.timeoutTimer = setTimeout(throwETIMEDOUT, delays.connect);
+		req.once('socket', socket => {
+			if (socket.connecting) {
+				const timeConnect = () => {
+					const cancelTimeout = addTimeout(
+						delays.connect,
+						timeoutHandler,
+						'connect'
+					);
+					cancelers.push(cancelTimeout);
+					return cancelTimeout;
+				};
+				if (req.socketPath || net.isIP(hostname || host)) {
+					socket.once('connect', timeConnect());
+				} else {
+					socket.once('lookup', () => {
+						socket.once('connect', timeConnect());
+					});
+				}
+			}
+		});
 	}
-
-	if (delays.request !== undefined) {
-		req.requestTimeoutTimer = setTimeout(() => {
-			clear();
-
-			if (req.connection.connecting) {
-				throwETIMEDOUT();
+	if (delays.send !== undefined) {
+		req.once('socket', socket => {
+			const timeRequest = () => {
+				const cancelTimeout = addTimeout(
+					delays.send,
+					timeoutHandler,
+					'send'
+				);
+				cancelers.push(cancelTimeout);
+				return cancelTimeout;
+			};
+			if (socket.connecting) {
+				socket.once('connect', () => {
+					req.once('upload-complete', timeRequest());
+				});
 			} else {
-				throwESOCKETTIMEDOUT();
+				req.once('upload-complete', timeRequest());
 			}
-		}, delays.request);
-	}
-
-	// Clear the connection timeout timer once a socket is assigned to the
-	// request and is connected.
-	req.on('socket', socket => {
-		// Socket may come from Agent pool and may be already connected.
-		if (!socket.connecting) {
-			connect();
-			return;
-		}
-
-		socket.once('connect', connect);
-	});
-
-	function clear() {
-		if (req.timeoutTimer) {
-			clearTimeout(req.timeoutTimer);
-			req.timeoutTimer = null;
-		}
+		});
 	}
-
-	function connect() {
-		clear();
-
-		if (delays.socket !== undefined) {
-			// Abort the request if there is no activity on the socket for more
-			// than `delays.socket` milliseconds.
-			req.setTimeout(delays.socket, throwESOCKETTIMEDOUT);
-		}
-
-		req.on('response', res => {
-			res.on('end', () => {
-				// The request is finished, cancel request timeout.
-				clearTimeout(req.requestTimeoutTimer);
-			});
+	if (delays.response !== undefined) {
+		req.once('upload-complete', () => {
+			const cancelTimeout = addTimeout(
+				delays.response,
+				timeoutHandler,
+				'response'
+			);
+			cancelers.push(cancelTimeout);
+			req.once('response', cancelTimeout);
 		});
 	}
-
-	return req.on('error', clear);
 };


Test patch:
diff --git a/test/create.js b/test/create.js
index c5ad429..b08b62a 100644
--- a/test/create.js
+++ b/test/create.js
@@ -76,17 +76,16 @@ test('custom headers (extend)', async t => {
 	t.is(headers.unicorn, 'rainbow');
 });
 
-test('custom endpoint with custom headers (create)', async t => {
-	const options = {headers: {unicorn: 'rainbow'}};
-	const handler = (url, options, next) => {
-		url = `${s.url}` + url;
-
-		return next(url, options);
-	};
-	const methods = ['get'];
-
-	const instance = got.create({options, methods, handler});
-	const headers = (await instance('/', {
+test('create', async t => {
+	const instance = got.create({
+		options: {},
+		methods: ['get'],
+		handler: (options, next) => {
+			options.headers.unicorn = 'rainbow';
+			return next(options);
+		}
+	});
+	const headers = (await instance(s.url, {
 		json: true
 	})).body;
 	t.is(headers.unicorn, 'rainbow');
@@ -99,17 +98,16 @@ test('custom endpoint with custom headers (extend)', async t => {
 		json: true
 	})).body;
 	t.is(headers.unicorn, 'rainbow');
-	t.is(headers['user-agent'] === undefined, false);
+	t.not(headers['user-agent'], undefined);
 });
 
 test('no tampering with defaults', t => {
 	const instance = got.create({
 		handler: got.defaults.handler,
 		methods: got.defaults.methods,
-		options: {
-			...got.defaults.options,
+		options: got.assignOptions(got.defaults.options, {
 			baseUrl: 'example'
-		}
+		})
 	});
 
 	const instance2 = instance.create({
diff --git a/test/retry.js b/test/retry.js
index a25c7a8..364097b 100644
--- a/test/retry.js
+++ b/test/retry.js
@@ -10,8 +10,7 @@ let fifth = 0;
 let lastTried413access = Date.now();
 
 const retryAfterOn413 = 2;
-const connectTimeout = 500;
-const socketTimeout = 100;
+const socketTimeout = 200;
 
 test.before('setup', async () => {
 	s = await createServer();
@@ -69,12 +68,12 @@ test.before('setup', async () => {
 });
 
 test('works on timeout error', async t => {
-	t.is((await got(`${s.url}/knock-twice`, {timeout: {connect: connectTimeout, socket: socketTimeout}})).body, 'who`s there?');
+	t.is((await got(`${s.url}/knock-twice`, {timeout: {socket: socketTimeout}})).body, 'who`s there?');
 });
 
 test('can be disabled with option', async t => {
 	const err = await t.throws(got(`${s.url}/try-me`, {
-		timeout: {connect: connectTimeout, socket: socketTimeout},
+		timeout: {socket: socketTimeout},
 		retry: 0
 	}));
 	t.truthy(err);
@@ -83,7 +82,7 @@ test('can be disabled with option', async t => {
 
 test('function gets iter count', async t => {
 	await got(`${s.url}/fifth`, {
-		timeout: {connect: connectTimeout, socket: socketTimeout},
+		timeout: {socket: socketTimeout},
 		retry: {
 			retries: iteration => iteration < 10
 		}
@@ -93,7 +92,7 @@ test('function gets iter count', async t => {
 
 test('falsy value prevents retries', async t => {
 	const err = await t.throws(got(`${s.url}/long`, {
-		timeout: {connect: connectTimeout, socket: socketTimeout},
+		timeout: {socket: socketTimeout},
 		retry: {
 			retries: () => 0
 		}
@@ -103,7 +102,7 @@ test('falsy value prevents retries', async t => {
 
 test('falsy value prevents retries #2', async t => {
 	const err = await t.throws(got(`${s.url}/long`, {
-		timeout: {connect: connectTimeout, socket: socketTimeout},
+		timeout: {socket: socketTimeout},
 		retry: {
 			retries: (iter, err) => {
 				t.truthy(err);
diff --git a/test/timeout.js b/test/timeout.js
index fb29a88..309dfab 100644
--- a/test/timeout.js
+++ b/test/timeout.js
@@ -1,3 +1,6 @@
+import http from 'http';
+import net from 'net';
+import stream from 'stream';
 import getStream from 'get-stream';
 import test from 'ava';
 import pEvent from 'p-event';
@@ -6,14 +9,48 @@ import got from '../source';
 import {createServer} from './helpers/server';
 
 let s;
-const reqDelay = 160;
+const reqDelay = 250;
+const slowDataStream = () => {
+	const slowStream = new stream.PassThrough();
+	let count = 0;
+	const interval = setInterval(() => {
+		if (count++ < 10) {
+			return slowStream.push('data\n'.repeat(100));
+		}
+		clearInterval(interval);
+		slowStream.push(null);
+	}, 100);
+	return slowStream;
+};
+const reqTimeout = reqDelay - 10;
+const errorMatcher = {
+	instanceOf: got.TimeoutError,
+	code: 'ETIMEDOUT'
+};
+const keepAliveAgent = new http.Agent({
+	keepAlive: true
+});
 
 test.before('setup', async () => {
 	s = await createServer();
 
 	s.on('/', async (req, res) => {
-		await delay(reqDelay);
-		res.statusCode = 200;
+		req.on('data', () => {});
+		req.on('end', async () => {
+			await delay(reqDelay);
+			res.end('OK');
+		});
+	});
+
+	s.on('/download', async (req, res) => {
+		res.writeHead(200, {
+			'transfer-encoding': 'chunked'
+		});
+		res.flushHeaders();
+		slowDataStream().pipe(res);
+	});
+
+	s.on('/prime', (req, res) => {
 		res.end('OK');
 	});
 
@@ -27,69 +64,246 @@ test('timeout option (ETIMEDOUT)', async t => {
 			retry: 0
 		}),
 		{
-			code: 'ETIMEDOUT'
+			...errorMatcher,
+			message: `Timeout awaiting 'request' for 0ms`
 		}
 	);
 });
 
-test('timeout option (ESOCKETTIMEDOUT)', async t => {
+test('timeout option as object (ETIMEDOUT)', async t => {
 	await t.throws(
 		got(s.url, {
-			timeout: reqDelay,
+			timeout: {socket: reqDelay * 2.5, request: 1},
 			retry: 0
 		}),
 		{
-			code: 'ESOCKETTIMEDOUT'
+			...errorMatcher,
+			message: `Timeout awaiting 'request' for 1ms`
 		}
 	);
 });
 
-test('timeout option as object (ETIMEDOUT)', async t => {
+test('socket timeout', async t => {
 	await t.throws(
 		got(s.url, {
-			timeout: {socket: reqDelay * 2.5, request: 0},
+			timeout: {socket: reqTimeout},
 			retry: 0
 		}),
 		{
-			code: 'ETIMEDOUT'
+			instanceOf: got.TimeoutError,
+			code: 'ETIMEDOUT',
+			message: `Timeout awaiting 'socket' for ${reqTimeout}ms`
 		}
 	);
 });
 
-test('timeout option as object (ESOCKETTIMEDOUT)', async t => {
+test('send timeout', async t => {
 	await t.throws(
 		got(s.url, {
-			timeout: {socket: reqDelay * 1.5, request: reqDelay},
+			timeout: {send: 1},
 			retry: 0
 		}),
 		{
-			code: 'ESOCKETTIMEDOUT'
+			...errorMatcher,
+			message: `Timeout awaiting 'send' for 1ms`
 		}
 	);
 });
 
-test('socket timeout', async t => {
+test('send timeout (keepalive)', async t => {
+	await got(`${s.url}/prime`, {agent: keepAliveAgent});
+	await t.throws(
+		got(s.url, {
+			agent: keepAliveAgent,
+			timeout: {send: 1},
+			retry: 0,
+			body: slowDataStream()
+		}).on('request', req => {
+			req.once('socket', socket => {
+				t.false(socket.connecting);
+				socket.once('connect', () => {
+					t.fail(`'connect' event fired, invalidating test`);
+				});
+			});
+		}),
+		{
+			...errorMatcher,
+			message: `Timeout awaiting 'send' for 1ms`
+		}
+	);
+});
+
+test('response timeout', async t => {
 	await t.throws(
 		got(s.url, {
-			timeout: {socket: reqDelay / 20},
+			timeout: {response: 1},
 			retry: 0
 		}),
 		{
-			code: 'ESOCKETTIMEDOUT'
+			...errorMatcher,
+			message: `Timeout awaiting 'response' for 1ms`
 		}
 	);
 });
 
-test.todo('connection timeout');
+test('response timeout unaffected by slow upload', async t => {
+	await got(s.url, {
+		timeout: {response: reqDelay * 2},
+		retry: 0,
+		body: slowDataStream()
+	}).on('request', request => {
+		request.on('error', error => {
+			t.fail(`unexpected error: ${error}`);
+		});
+	});
+	await delay(reqDelay * 3);
+	t.pass('no error emitted');
+});
+
+test('response timeout unaffected by slow download', async t => {
+	await got(`${s.url}/download`, {
+		timeout: {response: 100},
+		retry: 0
+	}).on('request', request => {
+		request.on('error', error => {
+			t.fail(`unexpected error: ${error}`);
+		});
+	});
+	await delay(reqDelay * 3);
+	t.pass('no error emitted');
+});
+
+test('response timeout (keepalive)', async t => {
+	await got(`${s.url}/prime`, {agent: keepAliveAgent});
+	await delay(100);
+	const request = got(s.url, {
+		agent: keepAliveAgent,
+		timeout: {response: 1},
+		retry: 0
+	}).on('request', req => {
+		req.once('socket', socket => {
+			t.false(socket.connecting);
+			socket.once('connect', () => {
+				t.fail(`'connect' event fired, invalidating test`);
+			});
+		});
+	});
+	await t.throws(request, {
+		...errorMatcher,
+		message: `Timeout awaiting 'response' for 1ms`
+	});
+});
+
+test('connect timeout', async t => {
+	await t.throws(
+		got({
+			host: s.host,
+			port: s.port,
+			createConnection: options => {
+				const socket = new net.Socket(options);
+				socket.connecting = true;
+				setImmediate(
+					socket.emit.bind(socket),
+					'lookup',
+					null,
+					'127.0.0.1',
+					4,
+					'localhost'
+				);
+				return socket;
+			}
+		}, {
+			timeout: {connect: 1},
+			retry: 0
+		}),
+		{
+			...errorMatcher,
+			message: `Timeout awaiting 'connect' for 1ms`
+		}
+	);
+});
+
+test('connect timeout (ip address)', async t => {
+	await t.throws(
+		got({
+			hostname: '127.0.0.1',
+			port: s.port,
+			createConnection: options => {
+				const socket = new net.Socket(options);
+				socket.connecting = true;
+				return socket;
+			}
+		}, {
+			timeout: {connect: 1},
+			retry: 0
+		}),
+		{
+			...errorMatcher,
+			message: `Timeout awaiting 'connect' for 1ms`
+		}
+	);
+});
+
+test('lookup timeout', async t => {
+	await t.throws(
+		got({
+			host: s.host,
+			port: s.port,
+			lookup: () => { }
+		}, {
+			timeout: {lookup: 1},
+			retry: 0
+		}),
+		{
+			...errorMatcher,
+			message: `Timeout awaiting 'lookup' for 1ms`
+		}
+	);
+});
+
+test('lookup timeout no error (ip address)', async t => {
+	await got({
+		hostname: '127.0.0.1',
+		port: s.port
+	}, {
+		timeout: {lookup: 100},
+		retry: 0
+	}).on('request', request => {
+		request.on('error', error => {
+			t.fail(`error emitted: ${error}`);
+		});
+	});
+	await delay(100);
+	t.pass('no error emitted');
+});
+
+test('lookup timeout no error (keepalive)', async t => {
+	await got(`${s.url}/prime`, {agent: keepAliveAgent});
+	await got(s.url, {
+		agent: keepAliveAgent,
+		timeout: {lookup: 100},
+		retry: 0
+	}).on('request', request => {
+		request.once('connect', () => {
+			t.fail('connect event fired, invalidating test');
+		});
+		request.on('error', error => {
+			t.fail(`error emitted: ${error}`);
+		});
+	});
+	await delay(100);
+	t.pass('no error emitted');
+});
 
 test('request timeout', async t => {
 	await t.throws(
 		got(s.url, {
-			timeout: {request: reqDelay},
+			timeout: {request: reqTimeout},
 			retry: 0
 		}),
 		{
-			code: 'ESOCKETTIMEDOUT'
+			...errorMatcher,
+			message: `Timeout awaiting 'request' for ${reqTimeout}ms`
 		}
 	);
 });
@@ -98,7 +312,7 @@ test('retries on timeout (ESOCKETTIMEDOUT)', async t => {
 	let tried = false;
 
 	await t.throws(got(s.url, {
-		timeout: reqDelay,
+		timeout: reqTimeout,
 		retry: {
 			retries: () => {
 				if (tried) {
@@ -110,7 +324,8 @@ test('retries on timeout (ESOCKETTIMEDOUT)', async t => {
 			}
 		}
 	}), {
-		code: 'ESOCKETTIMEDOUT'
+		...errorMatcher,
+		message: `Timeout awaiting 'request' for ${reqTimeout}ms`
 	});
 
 	t.true(tried);
@@ -131,7 +346,7 @@ test('retries on timeout (ETIMEDOUT)', async t => {
 				return 1;
 			}
 		}
-	}), {code: 'ETIMEDOUT'});
+	}), {...errorMatcher});
 
 	t.true(tried);
 });