Repository: sindresorhus/got (ts)

Issue:
Proxy headers in got.stream
I use `got@6.7.1`. I need to proxy an url from some upstream. So, I do
```
got.stream(url).pipe(res);
```
where `res` is an express response.

But it proxies only body, not headers. To proxy headers as well I have to add an extra handler
```
got.stream(url)
    .on('response', (msg) => {
        res.set(msg.headers);
    })
    .pipe(res);
```

Is it expected behaviour? For example `request` library proxies body and headers out of the box.

Pull request description:
Set headers on stream proxy
Closes #401.

Patch:
diff --git a/package.json b/package.json
index 5b13c9c..9ce6846 100644
--- a/package.json
+++ b/package.json
@@ -40,7 +40,6 @@
 		"duplexer3": "^0.1.4",
 		"extend": "^3.0.1",
 		"get-stream": "^3.0.0",
-		"is-retry-allowed": "^1.1.0",
 		"mimic-response": "^1.0.0",
 		"p-cancelable": "^0.5.0",
 		"to-readable-stream": "^1.0.0",
diff --git a/readme.md b/readme.md
index 229c164..5e20aaf 100644
--- a/readme.md
+++ b/readme.md
@@ -190,16 +190,31 @@ Milliseconds to wait for the server to end the response before aborting request
 
 This also accepts an object with separate `connect`, `socket`, and `request` fields for connection, socket, and entire request timeouts.
 
-###### retries
+###### retry
 
-Type: `number` `Function`<br>
-Default: `2`
+Type: `number` `Object`<br>
+Default:
+- retries: `2`
+- methods: `GET` `PUT` `HEAD` `DELETE` `OPTIONS` `TRACE`
+- statusCodes: [`408`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) [`413`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/413) [`429`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) [`502`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/502) [`503`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503) [`504`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/504)
+- maxRetryAfter: `undefined`
 
-Number of request retries when network errors happens. Delays between retries counts with function `1000 * Math.pow(2, retry) + Math.random() * 100`, where `retry` is attempt number (starts from 0).
+Object representing `retries`, `methods`, `statusCodes` and `maxRetryAfter` fields for time until retry, allowed methods, allowed status codes and maximum [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) time.
 
-Option accepts `function` with `retry` and `error` arguments. Function must return delay in milliseconds (`0` return value cancels retry).
+If `maxRetryAfter` is set to `undefined`, it will use `options.timeout`.<br>
+If [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) header is greater than `maxRetryAfter`, it will cancel the request.
 
-**Note:** if `retries` is `number`, `ENOTFOUND` and `ENETUNREACH` error will not be retried (see full list in [`is-retry-allowed`](https://github.com/floatdrop/is-retry-allowed/blob/master/index.js#L12) module).
+Delays between retries counts with function `1000 * Math.pow(2, retry) + Math.random() * 100`, where `retry` is attempt number (starts from 0).
+
+Option `retries` can be a `number`, but also accepts a `function` with `retry` and `error` arguments. Function must return delay in milliseconds (`0` return value cancels retry).
+
+**Note:** It retries only on the specified methods, status codes, and on these network errors:
+- `ETIMEDOUT`: Connection was not estabilished after a period of time.
+- `ECONNRESET`: Connection was forcibly closed by a peer.
+- `EADDRINUSE`: Could not bind to any free port.
+- `ESOCKETTIMEDOUT`: Connected, but received no response after a period of time.
+- `ECONNREFUSED`: Connection was refused by the server.
+- `EPIPE`: The remote side of the stream being written has been closed.
 
 ###### followRedirect
 
@@ -243,6 +258,24 @@ Determines if a `got.HTTPError` is thrown for error responses (non-2xx status co
 
 If this is disabled, requests that encounter an error status code will be resolved with the `response` instead of throwing. This may be useful if you are checking for resource availability and are expecting error responses.
 
+###### hooks
+
+Type: `Object<string, Array<Function>>`<br>
+Default: `{ beforeRequest: [] }`
+
+Hooks allow modifications during the request lifecycle. Hook functions may be async and are run serially.
+
+###### hooks.beforeRequest
+
+Type: `Array<Function>`<br>
+Default: `[]`
+
+Called with the normalized request options. Got will make no further changes to the request before it is sent. This is especially useful in conjunction with [`got.extend()`](#instances) and [`got.create()`](advanced-creation.md) when you want to create an API client that uses HMAC-signing.
+
+See the [AWS section](#aws) for an example.
+
+**Note**: Modifying the `body` is not recommended because the `content-length` header has already been computed and assigned.
+
 #### Streams
 
 **Note**: Progress events, redirect events and request/response events can also be used with promises.
@@ -611,47 +644,35 @@ got('http://unix:/var/run/docker.sock:/containers/json');
 got('unix:/var/run/docker.sock:/containers/json');
 ```
 
+
 ## AWS
 
-Requests to AWS services need to have their headers signed. This can be accomplished by using the [`aws4`](https://www.npmjs.com/package/aws4) package. This is an example for querying an ["Elasticsearch Service"](https://aws.amazon.com/elasticsearch-service/) host with a signed request.
+Requests to AWS services need to have their headers signed. This can be accomplished by using the [`aws4`](https://www.npmjs.com/package/aws4) package. This is an example for querying an ["API Gateway"](https://docs.aws.amazon.com/apigateway/api-reference/signing-requests/) with a signed request.
 
 ```js
-const url = require('url');
 const AWS = require('aws-sdk');
 const aws4 = require('aws4');
 const got = require('got');
-const config = require('./config');
-
-// Reads keys from the environment or `~/.aws/credentials`. Could be a plain object.
-const awsConfig = new AWS.Config({ region: config.region });
-
-function request(url, options) {
-	const awsOpts = {
-		region: awsConfig.region,
-		headers: {
-			accept: 'application/json',
-			'content-type': 'application/json'
-		},
-		method: 'GET',
-		json: true
-	};
-
-	// We need to parse the URL before passing it to `got` so `aws4` can sign the request
-	options = {
-		...url.parse(url),
-		...awsOpts,
-		...options
-	};
-
-	aws4.sign(options, awsConfig.credentials);
-
-	return got(options);
-}
 
-request(`https://${config.host}/production/users/1`);
+const credentials = await new AWS.CredentialProviderChain().resolvePromise();
+
+// Create a Got instance to use relative paths and signed requests
+const awsClient = got.extend(
+	{
+		baseUrl: 'https://<api-id>.execute-api.<api-region>.amazonaws.com/<stage>/',
+		hooks: {
+			beforeRequest: [
+				async options => {
+					await credentials.getPromise();
+					aws4.sign(options, credentials);
+				}
+			]
+		}
+	}
+);
 
-request(`https://${config.host}/production/`, {
-	// All usual `got` options
+const response = await awsClient('endpoint/path', {
+	// Request-specific options
 });
 ```
 
diff --git a/source/as-promise.js b/source/as-promise.js
index 00808a6..fcd261d 100644
--- a/source/as-promise.js
+++ b/source/as-promise.js
@@ -67,19 +67,31 @@ module.exports = options => {
 				}
 			}
 
-			if (options.throwHttpErrors && statusCode !== 304 && (statusCode < 200 || statusCode > limitStatusCode)) {
+			if (statusCode !== 304 && (statusCode < 200 || statusCode > limitStatusCode)) {
 				const error = new HTTPError(statusCode, response.statusMessage, response.headers, options);
 				Object.defineProperty(error, 'response', {value: response});
-				reject(error);
+				emitter.emit('retry', error, retried => {
+					if (!retried) {
+						if (options.throwHttpErrors) {
+							reject(error);
+							return;
+						}
+
+						resolve(response);
+					}
+				});
+				return;
 			}
 
 			resolve(response);
 		});
 
 		emitter.once('error', reject);
-		emitter.on('redirect', proxy.emit.bind(proxy, 'redirect'));
-		emitter.on('uploadProgress', proxy.emit.bind(proxy, 'uploadProgress'));
-		emitter.on('downloadProgress', proxy.emit.bind(proxy, 'downloadProgress'));
+		[
+			'redirect',
+			'uploadProgress',
+			'downloadProgress'
+		].forEach(event => emitter.on(event, (...args) => proxy.emit(event, ...args)));
 	});
 
 	const promise = cancelable;
diff --git a/source/as-stream.js b/source/as-stream.js
index 0118272..35f14c5 100644
--- a/source/as-stream.js
+++ b/source/as-stream.js
@@ -9,6 +9,10 @@ module.exports = options => {
 	const input = new PassThrough();
 	const output = new PassThrough();
 	const proxy = duplexer3(input, output);
+	const piped = new Set();
+	let isFinished = false;
+
+	options.gotRetry.retries = () => 0;
 
 	if (options.json) {
 		throw new Error('Got can not be used as a stream when the `json` option is used');
@@ -50,20 +54,60 @@ module.exports = options => {
 			proxy.emit('error', new ReadError(error, options));
 		});
 
-		response.pipe(output);
-
 		if (options.throwHttpErrors && statusCode !== 304 && (statusCode < 200 || statusCode > 299)) {
 			proxy.emit('error', new HTTPError(statusCode, response.statusMessage, response.headers, options), null, response);
 			return;
 		}
 
+		isFinished = true;
+
+		response.pipe(output);
+
+		for (const destination of piped) {
+			if (destination.headersSent) {
+				continue;
+			}
+
+			for (const [key, value] of Object.entries(response.headers)) {
+				// Got gives *uncompressed* data. Overriding `content-encoding` header would result in an error.
+				// It's not possible to decompress uncompressed data, is it?
+				if (key.toLowerCase() !== 'content-encoding') {
+					destination.setHeader(key, value);
+				}
+			}
+
+			destination.statusCode = response.statusCode;
+		}
+
 		proxy.emit('response', response);
 	});
 
-	emitter.on('error', proxy.emit.bind(proxy, 'error'));
-	emitter.on('redirect', proxy.emit.bind(proxy, 'redirect'));
-	emitter.on('uploadProgress', proxy.emit.bind(proxy, 'uploadProgress'));
-	emitter.on('downloadProgress', proxy.emit.bind(proxy, 'downloadProgress'));
+	[
+		'error',
+		'redirect',
+		'uploadProgress',
+		'downloadProgress'
+	].forEach(event => emitter.on(event, (...args) => proxy.emit(event, ...args)));
+
+	const pipe = proxy.pipe.bind(proxy);
+	const unpipe = proxy.unpipe.bind(proxy);
+	proxy.pipe = (destination, options) => {
+		if (isFinished) {
+			throw new Error('Failed to pipe. The response has been emitted already.');
+		}
+
+		const result = pipe(destination, options);
+
+		if (Reflect.has(destination, 'setHeader')) {
+			piped.add(destination);
+		}
+
+		return result;
+	};
+	proxy.unpipe = stream => {
+		piped.delete(stream);
+		return unpipe(stream);
+	};
 
 	return proxy;
 };
diff --git a/source/assign-options.js b/source/assign-options.js
index efe18d8..5127ff4 100644
--- a/source/assign-options.js
+++ b/source/assign-options.js
@@ -8,10 +8,20 @@ module.exports = (defaults, options = {}) => {
 		for (const [key, value] of Object.entries(options.headers)) {
 			if (is.nullOrUndefined(value)) {
 				delete opts.headers[key];
-				continue;
 			}
 		}
 	}
 
+	// Override these arrays because we don't want to extend them
+	if (is.object(options.retry)) {
+		if (Reflect.has(options.retry, 'methods')) {
+			opts.retry.methods = options.retry.methods;
+		}
+
+		if (Reflect.has(options.retry, 'statusCodes')) {
+			opts.retry.statusCodes = options.retry.statusCodes;
+		}
+	}
+
 	return opts;
 };
diff --git a/source/create.js b/source/create.js
index f61bfb5..55e173a 100644
--- a/source/create.js
+++ b/source/create.js
@@ -7,14 +7,14 @@ const asPromise = require('./as-promise');
 const normalizeArguments = require('./normalize-arguments');
 const deepFreeze = require('./deep-freeze');
 
-const next = (path, options) => {
+const makeNext = defaults => (path, options) => {
 	let url = path;
 
 	if (options.baseUrl) {
 		url = new URLGlobal(path, options.baseUrl);
 	}
 
-	options = normalizeArguments(url, options);
+	options = normalizeArguments(url, options, defaults);
 
 	if (options.stream) {
 		return asStream(options);
@@ -24,6 +24,7 @@ const next = (path, options) => {
 };
 
 const create = defaults => {
+	const next = makeNext(defaults);
 	if (!defaults.handler) {
 		defaults.handler = next;
 	}
diff --git a/source/index.js b/source/index.js
index 0c188c6..f4573f8 100644
--- a/source/index.js
+++ b/source/index.js
@@ -12,13 +12,20 @@ const defaults = {
 		'delete'
 	],
 	options: {
-		retries: 2,
+		retry: {
+			retries: 2,
+			methods: ['GET', 'PUT', 'HEAD', 'DELETE', 'OPTIONS', 'TRACE'],
+			statusCodes: [408, 413, 429, 502, 503, 504]
+		},
 		cache: false,
 		decompress: true,
 		useElectronNet: false,
 		throwHttpErrors: true,
 		headers: {
 			'user-agent': `${pkg.name}/${pkg.version} (https://github.com/sindresorhus/got)`
+		},
+		hooks: {
+			beforeRequest: []
 		}
 	}
 };
diff --git a/source/is-retry-on-network-error-allowed.js b/source/is-retry-on-network-error-allowed.js
new file mode 100644
index 0000000..3826d0c
--- /dev/null
+++ b/source/is-retry-on-network-error-allowed.js
@@ -0,0 +1,18 @@
+'use strict';
+
+const WHITELIST = new Set([
+	'ETIMEDOUT',
+	'ECONNRESET',
+	'EADDRINUSE',
+	'ESOCKETTIMEDOUT',
+	'ECONNREFUSED',
+	'EPIPE'
+]);
+
+module.exports = err => {
+	if (err && WHITELIST.has(err.code)) {
+		return true;
+	}
+
+	return false;
+};
diff --git a/source/normalize-arguments.js b/source/normalize-arguments.js
index a285450..e09e97d 100644
--- a/source/normalize-arguments.js
+++ b/source/normalize-arguments.js
@@ -1,13 +1,16 @@
 'use strict';
 const URLSearchParamsGlobal = typeof URLSearchParams === 'undefined' ? require('url').URLSearchParams : URLSearchParams; // TODO: Use the `URL` global when targeting Node.js 10
 const is = require('@sindresorhus/is');
-const isRetryAllowed = require('is-retry-allowed');
 const toReadableStream = require('to-readable-stream');
 const urlParseLax = require('url-parse-lax');
+const isRetryOnNetworkErrorAllowed = require('./is-retry-on-network-error-allowed');
 const urlToOptions = require('./url-to-options');
 const isFormData = require('./is-form-data');
 
-module.exports = (url, options) => {
+const retryAfterStatusCodes = new Set([413, 429, 503]);
+const knownHookEvents = ['beforeRequest'];
+
+module.exports = (url, options, defaults) => {
 	if (Reflect.has(options, 'url') || (is.object(url) && Reflect.has(url, 'url'))) {
 		throw new TypeError('Parameter `url` is not an option. Use got(url, options)');
 	}
@@ -61,25 +64,26 @@ module.exports = (url, options) => {
 		options.method = (options.method || 'GET').toUpperCase();
 	} else {
 		const {headers} = options;
+		const isObject = is.object(body) && !Buffer.isBuffer(body) && !is.nodeStream(body);
 		if (!is.nodeStream(body) && !is.string(body) && !is.buffer(body) && !(options.form || options.json)) {
 			throw new TypeError('The `body` option must be a stream.Readable, string or Buffer');
 		}
 
-		if (options.json && !(is.plainObject(body) || is.array(body))) {
-			throw new TypeError('The `body` option must be a plain Object or Array when the `json` option is used');
+		if (options.json && !(isObject || is.array(body))) {
+			throw new TypeError('The `body` option must be an Object or Array when the `json` option is used');
 		}
 
-		if (options.form && !is.plainObject(body)) {
-			throw new TypeError('The `body` option must be a plain Object when the `form` option is used');
+		if (options.form && !isObject) {
+			throw new TypeError('The `body` option must be an Object when the `form` option is used');
 		}
 
 		if (isFormData(body)) {
 			// Special case for https://github.com/form-data/form-data
 			headers['content-type'] = headers['content-type'] || `multipart/form-data; boundary=${body.getBoundary()}`;
-		} else if (options.form && is.plainObject(body)) {
+		} else if (options.form) {
 			headers['content-type'] = headers['content-type'] || 'application/x-www-form-urlencoded';
 			options.body = (new URLSearchParamsGlobal(body)).toString();
-		} else if (options.json && (is.plainObject(body) || is.array(body))) {
+		} else if (options.json) {
 			headers['content-type'] = headers['content-type'] || 'application/json';
 			options.body = JSON.stringify(body);
 		}
@@ -112,17 +116,61 @@ module.exports = (url, options) => {
 		}
 	}
 
-	if (!is.function(options.retries)) {
-		const {retries} = options;
+	options.gotRetry = {retries: 0, methods: [], statusCodes: []};
+	if (options.retry !== false) {
+		if (is.number(options.retry)) {
+			if (is.object(defaults.options.retry)) {
+				options.gotRetry = {...defaults.options.retry, retries: options.retry};
+			} else {
+				options.gotRetry.retries = options.retry;
+			}
+		} else {
+			options.gotRetry = {...options.gotRetry, ...options.retry};
+		}
+		delete options.retry;
+	}
+
+	options.gotRetry.methods = new Set(options.gotRetry.methods.map(method => method.toUpperCase()));
+	options.gotRetry.statusCodes = new Set(options.gotRetry.statusCodes);
 
-		options.retries = (iter, error) => {
-			if (iter > retries || !isRetryAllowed(error)) {
+	if (!options.gotRetry.maxRetryAfter && Reflect.has(options, 'timeout')) {
+		if (is.number(options.timeout)) {
+			options.gotRetry.maxRetryAfter = options.timeout;
+		} else {
+			options.gotRetry.maxRetryAfter = Math.min(...[options.timeout.request, options.timeout.connection].filter(n => !is.nullOrUndefined(n)));
+		}
+	}
+
+	if (!is.function(options.gotRetry.retries)) {
+		const {retries} = options.gotRetry;
+
+		options.gotRetry.retries = (iteration, error) => {
+			if (iteration > retries || (!isRetryOnNetworkErrorAllowed(error) && (!options.gotRetry.methods.has(error.method) || !options.gotRetry.statusCodes.has(error.statusCode)))) {
+				return 0;
+			}
+
+			if (Reflect.has(error, 'headers') && Reflect.has(error.headers, 'retry-after') && retryAfterStatusCodes.has(error.statusCode)) {
+				let after = Number(error.headers['retry-after']);
+				if (is.number(after)) {
+					after *= 1000;
+				} else {
+					after = Math.max(Date.parse(error.headers['retry-after']) - Date.now(), 0);
+				}
+
+				if (after > options.gotRetry.maxRetryAfter) {
+					return 0;
+				}
+
+				return after;
+			}
+
+			if (error.statusCode === 413) {
 				return 0;
 			}
 
 			const noise = Math.random() * 100;
 
-			return ((1 << iter) * 1000) + noise;
+			return ((1 << iteration) * 1000) + noise;
 		};
 	}
 
@@ -139,5 +187,31 @@ module.exports = (url, options) => {
 		delete options.timeout;
 	}
 
+	if (is.nullOrUndefined(options.hooks)) {
+		options.hooks = {};
+	}
+	if (is.object(options.hooks)) {
+		for (const hookEvent of knownHookEvents) {
+			const hooks = options.hooks[hookEvent];
+			if (is.nullOrUndefined(hooks)) {
+				options.hooks[hookEvent] = [];
+			} else if (is.array(hooks)) {
+				hooks.forEach(
+					(hook, index) => {
+						if (!is.function_(hook)) {
+							throw new TypeError(
+								`Parameter \`hooks.${hookEvent}[${index}]\` must be a function, not ${is(hook)}`
+							);
+						}
+					}
+				);
+			} else {
+				throw new TypeError(`Parameter \`hooks.${hookEvent}\` must be an array, not ${is(hooks)}`);
+			}
+		}
+	} else {
+		throw new TypeError(`Parameter \`hooks\` must be an object, not ${is(options.hooks)}`);
+	}
+
 	return options;
 };
diff --git a/source/request-as-event-emitter.js b/source/request-as-event-emitter.js
index fd2f39c..80a9ee6 100644
--- a/source/request-as-event-emitter.js
+++ b/source/request-as-event-emitter.js
@@ -20,6 +20,7 @@ module.exports = (options = {}) => {
 	const redirects = [];
 	const agents = is.object(options.agent) ? options.agent : null;
 	let retryCount = 0;
+	let retryTries = 0;
 	let redirectUrl;
 	let uploadBodySize;
 	let uploaded = 0;
@@ -56,6 +57,7 @@ module.exports = (options = {}) => {
 
 			const {statusCode} = response;
 
+			response.retryCount = retryCount;
 			response.url = redirectUrl || requestUrl;
 			response.requestUrl = requestUrl;
 
@@ -131,20 +133,12 @@ module.exports = (options = {}) => {
 					return;
 				}
 
-				const backoff = options.retries(++retryCount, error);
-
-				if (backoff) {
-					setTimeout(options => {
-						try {
-							get(options);
-						} catch (error2) {
-							emitter.emit('error', error2);
-						}
-					}, backoff, options);
-					return;
-				}
-
-				emitter.emit('error', new RequestError(error, options));
+				const err = new RequestError(error, options);
+				emitter.emit('retry', err, retried => {
+					if (!retried) {
+						emitter.emit('error', err);
+					}
+				});
 			});
 
 			emitter.once('request', req => {
@@ -212,6 +206,25 @@ module.exports = (options = {}) => {
 		});
 	};
 
+	emitter.on('retry', (error, cb) => {
+		const backoff = options.gotRetry.retries(++retryTries, error);
+
+		if (backoff) {
+			retryCount++;
+			setTimeout(options => {
+				try {
+					get(options);
+				} catch (error2) {
+					emitter.emit('error', error2);
+				}
+			}, backoff, options);
+			cb(true);
+			return;
+		}
+
+		cb(false);
+	});
+
 	setImmediate(async () => {
 		try {
 			uploadBodySize = await getBodySize(options);
@@ -228,6 +241,11 @@ module.exports = (options = {}) => {
 				options.headers['content-length'] = uploadBodySize;
 			}
 
+			for (const hook of options.hooks.beforeRequest) {
+				// eslint-disable-next-line no-await-in-loop
+				await hook(options);
+			}
+
 			get(options);
 		} catch (error) {
 			emitter.emit('error', error);
diff --git a/source/timed-out.js b/source/timed-out.js
index c293838..6ea31e5 100644
--- a/source/timed-out.js
+++ b/source/timed-out.js
@@ -2,12 +2,11 @@
 
 // Forked from https://github.com/floatdrop/timed-out
 
-module.exports = function (req, time) {
+module.exports = function (req, delays) {
 	if (req.timeoutTimer) {
 		return req;
 	}
 
-	const delays = isNaN(time) ? time : {request: time};
 	const host = req._headers ? (' to ' + req._headers.host) : '';
 
 	function throwESOCKETTIMEDOUT() {
diff --git a/source/url-to-options.js b/source/url-to-options.js
index df94c97..848ef30 100644
--- a/source/url-to-options.js
+++ b/source/url-to-options.js
@@ -1,6 +1,6 @@
 'use strict';
+const is = require('@sindresorhus/is');
 
-// From: https://github.com/nodejs/node/blob/8476053c132fd9613aab547aba165190f8064254/lib/internal/url.js#L1318-L1340
 module.exports = url => {
 	const options = {
 		protocol: url.protocol,
@@ -8,11 +8,10 @@ module.exports = url => {
 		hash: url.hash,
 		search: url.search,
 		pathname: url.pathname,
-		path: `${url.pathname}${url.search}`,
 		href: url.href
 	};
 
-	if (url.port !== '') {
+	if (is.string(url.port) && url.port.length > 0) {
 		options.port = Number(url.port);
 	}
 
@@ -20,5 +19,7 @@ module.exports = url => {
 		options.auth = `${url.username}:${url.password}`;
 	}
 
+	options.path = is.null(url.search) ? url.pathname : `${url.pathname}${url.search}`;
+
 	return options;
 };


Test patch:
diff --git a/test/arguments.js b/test/arguments.js
index 964941a..ab70ab2 100644
--- a/test/arguments.js
+++ b/test/arguments.js
@@ -101,6 +101,40 @@ test('throws TypeError when `url` is passed as an option', async t => {
 	await t.throws(got({url: 'example.com'}), {instanceOf: TypeError});
 });
 
+test('throws TypeError when `hooks` is not an object', async t => {
+	await t.throws(
+		() => got(s.url, {hooks: 'not object'}),
+		{
+			instanceOf: TypeError,
+			message: 'Parameter `hooks` must be an object, not string'
+		}
+	);
+});
+
+test('throws TypeError when known `hooks` value is not an array', async t => {
+	await t.throws(
+		() => got(s.url, {hooks: {beforeRequest: {}}}),
+		{
+			instanceOf: TypeError,
+			message: 'Parameter `hooks.beforeRequest` must be an array, not Object'
+		}
+	);
+});
+
+test('throws TypeError when known `hooks` array item is not a function', async t => {
+	await t.throws(
+		() => got(s.url, {hooks: {beforeRequest: [{}]}}),
+		{
+			instanceOf: TypeError,
+			message: 'Parameter `hooks.beforeRequest[0]` must be a function, not Object'
+		}
+	);
+});
+
+test('allows extra keys in `hooks`', async t => {
+	await t.notThrows(() => got(`${s.url}/test`, {hooks: {extra: {}}}));
+});
+
 test.after('cleanup', async () => {
 	await s.close();
 });
diff --git a/test/cancel.js b/test/cancel.js
index 17e9747..cee5462 100644
--- a/test/cancel.js
+++ b/test/cancel.js
@@ -42,8 +42,10 @@ test('cancel do not retry after cancelation', async t => {
 	const helper = await createAbortServer();
 
 	const p = got(helper.redirectUrl, {
-		retries: _ => {
-			t.fail('Makes a new try after cancelation');
+		retry: {
+			retries: _ => {
+				t.fail('Makes a new try after cancelation');
+			}
 		}
 	});
 
diff --git a/test/error.js b/test/error.js
index a905a21..0ba0bfa 100644
--- a/test/error.js
+++ b/test/error.js
@@ -1,6 +1,7 @@
 import http from 'http';
 import test from 'ava';
 import sinon from 'sinon';
+import getStream from 'get-stream';
 import proxyquire from 'proxyquire';
 import got from '../source';
 import {createServer} from './helpers/server';
@@ -26,6 +27,11 @@ test.before('setup', async () => {
 		res.end('body');
 	});
 
+	s.on('/body', async (req, res) => {
+		const body = await getStream(req);
+		res.end(body);
+	});
+
 	await s.listen(s.port);
 });
 
@@ -45,7 +51,7 @@ test('properties', async t => {
 });
 
 test('dns message', async t => {
-	const err = await t.throws(got('.com', {retries: 0}));
+	const err = await t.throws(got('.com', {retry: 0}));
 	t.truthy(err);
 	t.regex(err.message, /getaddrinfo ENOTFOUND/);
 	t.is(err.host, '.com');
@@ -53,18 +59,31 @@ test('dns message', async t => {
 });
 
 test('options.body error message', async t => {
-	const err = await t.throws(got(s.url, {body: {}}));
-	t.regex(err.message, /The `body` option must be a stream\.Readable, string or Buffer/);
+	await t.throws(got(s.url, {body: {}}), {
+		message: 'The `body` option must be a stream.Readable, string or Buffer'
+	});
 });
 
 test('options.body json error message', async t => {
-	const err = await t.throws(got(s.url, {body: Buffer.from('test'), json: true}));
-	t.regex(err.message, /The `body` option must be a plain Object or Array when the `json` option is used/);
+	await t.throws(got(s.url, {body: Buffer.from('test'), json: true}), {
+		message: 'The `body` option must be an Object or Array when the `json` option is used'
+	});
 });
 
 test('options.body form error message', async t => {
-	const err = await t.throws(got(s.url, {body: Buffer.from('test'), form: true}));
-	t.regex(err.message, /The `body` option must be a plain Object when the `form` option is used/);
+	await t.throws(got(s.url, {body: Buffer.from('test'), form: true}), {
+		message: 'The `body` option must be an Object when the `form` option is used'
+	});
+});
+
+test('no plain object restriction on body', async t => {
+	function CustomObject() {
+		this.a = 123;
+	}
+
+	const {body} = await got(`${s.url}/body`, {body: new CustomObject(), json: true});
+
+	t.deepEqual(body, {a: 123});
 });
 
 test('default status message', async t => {
@@ -83,9 +102,7 @@ test.serial('http.request error', async t => {
 	const stub = sinon.stub(http, 'request').callsFake(() => {
 		throw new TypeError('The header content contains invalid characters');
 	});
-	const err = await t.throws(got(s.url));
-	t.true(err instanceof got.RequestError);
-	t.is(err.message, 'The header content contains invalid characters');
+	await t.throws(got(s.url), {instanceOf: got.RequestError, message: 'The header content contains invalid characters'});
 	stub.restore();
 });
 
@@ -99,8 +116,7 @@ test.serial('catch error in mimicResponse', async t => {
 		'mimic-response': mimicResponse
 	});
 
-	const err = await t.throws(proxiedGot(s.url));
-	t.is(err.message, 'Error in mimic-response');
+	await t.throws(proxiedGot(s.url), {message: 'Error in mimic-response'});
 });
 
 test.after('cleanup', async () => {
diff --git a/test/helpers.js b/test/helpers.js
index 3a40336..6b74a67 100644
--- a/test/helpers.js
+++ b/test/helpers.js
@@ -25,7 +25,7 @@ test('promise mode', async t => {
 	const err = await t.throws(got.get(`${s.url}/404`));
 	t.is(err.response.body, 'not found');
 
-	const err2 = await t.throws(got.get('.com', {retries: 0}));
+	const err2 = await t.throws(got.get('.com', {retry: 0}));
 	t.truthy(err2);
 });
 
diff --git a/test/hooks.js b/test/hooks.js
new file mode 100644
index 0000000..220bf97
--- /dev/null
+++ b/test/hooks.js
@@ -0,0 +1,105 @@
+import test from 'ava';
+import delay from 'delay';
+import {createServer} from './helpers/server';
+import got from '..';
+
+let s;
+
+test.before('setup', async () => {
+	s = await createServer();
+	const echoHeaders = (req, res) => {
+		res.statusCode = 200;
+		res.write(JSON.stringify(req.headers));
+		res.end();
+	};
+	s.on('/', echoHeaders);
+	await s.listen(s.port);
+});
+
+test('beforeRequest receives normalized options', async t => {
+	await got(
+		s.url,
+		{
+			json: true,
+			hooks: {
+				beforeRequest: [
+					options => {
+						t.is(options.path, '/');
+						t.is(options.hostname, 'localhost');
+					}
+				]
+			}
+		}
+	);
+});
+
+test('beforeRequest allows modifications', async t => {
+	const res = await got(
+		s.url,
+		{
+			json: true,
+			hooks: {
+				beforeRequest: [
+					options => {
+						options.headers.foo = 'bar';
+					}
+				]
+			}
+		}
+	);
+	t.is(res.body.foo, 'bar');
+});
+
+test('beforeRequest awaits async function', async t => {
+	const res = await got(
+		s.url,
+		{
+			json: true,
+			hooks: {
+				beforeRequest: [
+					async options => {
+						await delay(100);
+						options.headers.foo = 'bar';
+					}
+				]
+			}
+		}
+	);
+	t.is(res.body.foo, 'bar');
+});
+
+test('beforeRequest rejects when beforeRequest throws', async t => {
+	await t.throws(
+		() => got(s.url, {
+			hooks: {
+				beforeRequest: [
+					() => {
+						throw new Error('oops');
+					}
+				]
+			}
+		}),
+		{
+			instanceOf: Error,
+			message: 'oops'
+		}
+	);
+});
+
+test('beforeRequest rejects when beforeRequest rejects', async t => {
+	await t.throws(
+		() => got(s.url, {
+			hooks: {
+				beforeRequest: [() => Promise.reject(new Error('oops'))]
+			}
+		}),
+		{
+			instanceOf: Error,
+			message: 'oops'
+		}
+	);
+});
+
+test.after('cleanup', async () => {
+	await s.close();
+});
diff --git a/test/promise.js b/test/promise.js
index 471e3c7..dbb7e59 100644
--- a/test/promise.js
+++ b/test/promise.js
@@ -1,3 +1,5 @@
+import {ClientRequest} from 'http';
+import {Transform} from 'stream';
 import test from 'ava';
 import {createServer} from './helpers/server';
 import got from '..';
@@ -14,14 +16,16 @@ test.before('setup', async () => {
 });
 
 test('should emit request event as promise', async t => {
-	await got(s.url, {json: true}).on('request', () => {
-		t.pass();
+	await got(s.url, {json: true}).on('request', req => {
+		t.true(req instanceof ClientRequest);
 	});
 });
 
 test('should emit response event as promise', async t => {
-	await got(s.url, {json: true}).on('response', () => {
-		t.pass();
+	await got(s.url, {json: true}).on('response', res => {
+		t.true(res instanceof Transform);
+		t.true(res.readable);
+		t.is(res.statusCode, 200);
 	});
 });
 
diff --git a/test/retry.js b/test/retry.js
index b7dd9a6..a25c7a8 100644
--- a/test/retry.js
+++ b/test/retry.js
@@ -1,4 +1,5 @@
 import test from 'ava';
+import pEvent from 'p-event';
 import got from '../source';
 import {createServer} from './helpers/server';
 
@@ -6,6 +7,11 @@ let s;
 let trys = 0;
 let knocks = 0;
 let fifth = 0;
+let lastTried413access = Date.now();
+
+const retryAfterOn413 = 2;
+const connectTimeout = 500;
+const socketTimeout = 100;
 
 test.before('setup', async () => {
 	s = await createServer();
@@ -28,17 +34,48 @@ test.before('setup', async () => {
 		}
 	});
 
+	s.on('/500', (req, res) => {
+		res.statusCode = 500;
+		res.end();
+	});
+
+	s.on('/measure413', (req, res) => {
+		res.writeHead(413, {
+			'Retry-After': retryAfterOn413
+		});
+		res.end((Date.now() - lastTried413access).toString());
+
+		lastTried413access = Date.now();
+	});
+
+	s.on('/413', (req, res) => {
+		res.writeHead(413, {
+			'Retry-After': retryAfterOn413
+		});
+		res.end();
+	});
+
+	s.on('/413withoutRetryAfter', (req, res) => {
+		res.statusCode = 413;
+		res.end();
+	});
+
+	s.on('/503', (req, res) => {
+		res.statusCode = 503;
+		res.end();
+	});
+
 	await s.listen(s.port);
 });
 
 test('works on timeout error', async t => {
-	t.is((await got(`${s.url}/knock-twice`, {timeout: {connect: 100, socket: 100}})).body, 'who`s there?');
+	t.is((await got(`${s.url}/knock-twice`, {timeout: {connect: connectTimeout, 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: 500, socket: 500},
-		retries: 0
+		timeout: {connect: connectTimeout, socket: socketTimeout},
+		retry: 0
 	}));
 	t.truthy(err);
 	t.is(trys, 1);
@@ -46,31 +83,128 @@ test('can be disabled with option', async t => {
 
 test('function gets iter count', async t => {
 	await got(`${s.url}/fifth`, {
-		timeout: {connect: 500, socket: 500},
-		retries: iter => iter < 10
+		timeout: {connect: connectTimeout, socket: socketTimeout},
+		retry: {
+			retries: iteration => iteration < 10
+		}
 	});
 	t.is(fifth, 6);
 });
 
 test('falsy value prevents retries', async t => {
 	const err = await t.throws(got(`${s.url}/long`, {
-		timeout: {connect: 100, socket: 100},
-		retries: () => 0
+		timeout: {connect: connectTimeout, socket: socketTimeout},
+		retry: {
+			retries: () => 0
+		}
 	}));
 	t.truthy(err);
 });
 
 test('falsy value prevents retries #2', async t => {
 	const err = await t.throws(got(`${s.url}/long`, {
-		timeout: {connect: 100, socket: 100},
-		retries: (iter, err) => {
-			t.truthy(err);
-			return false;
+		timeout: {connect: connectTimeout, socket: socketTimeout},
+		retry: {
+			retries: (iter, err) => {
+				t.truthy(err);
+				return false;
+			}
 		}
 	}));
 	t.truthy(err);
 });
 
+test('custom retries', async t => {
+	let tried = false;
+	const err = await t.throws(got(`${s.url}/500`, {
+		throwHttpErrors: true,
+		retry: {
+			retries: iter => {
+				if (iter === 1) {
+					tried = true;
+					return 1;
+				}
+
+				return 0;
+			}, methods: [
+				'GET'
+			], statusCodes: [
+				500
+			]
+		}
+	}));
+	t.is(err.statusCode, 500);
+	t.true(tried);
+});
+
+test('respect 413 Retry-After', async t => {
+	const {statusCode, body} = await got(`${s.url}/measure413`, {
+		throwHttpErrors: false,
+		retry: 1
+	});
+	t.is(statusCode, 413);
+	t.true(Number(body) >= retryAfterOn413 * 1000);
+});
+
+test('doesn\'t retry on 413 with empty statusCodes and methods', async t => {
+	const {statusCode, retryCount} = await got(`${s.url}/413`, {
+		throwHttpErrors: false,
+		retry: {
+			retries: 1,
+			statusCodes: [],
+			methods: []
+		}
+	});
+	t.is(statusCode, 413);
+	t.is(retryCount, 0);
+});
+
+test('doesn\'t retry on 413 with empty methods', async t => {
+	const {statusCode, retryCount} = await got(`${s.url}/413`, {
+		throwHttpErrors: false,
+		retry: {
+			retries: 1,
+			statusCodes: [413],
+			methods: []
+		}
+	});
+	t.is(statusCode, 413);
+	t.is(retryCount, 0);
+});
+
+test('doesn\'t retry on 413 without Retry-After header', async t => {
+	const {retryCount} = await got(`${s.url}/413withoutRetryAfter`, {
+		throwHttpErrors: false
+	});
+	t.is(retryCount, 0);
+});
+
+test('retries on 503 without Retry-After header', async t => {
+	const {retryCount} = await got(`${s.url}/503`, {
+		throwHttpErrors: false,
+		retry: 1
+	});
+	t.is(retryCount, 1);
+});
+
+test('doesn\'t retry on streams', async t => {
+	const stream = got.stream(s.url, {
+		timeout: 1,
+		retries: () => {
+			t.fail('Retries on streams');
+		}
+	});
+	await t.throws(pEvent(stream, 'response'));
+});
+
+test('doesn\'t retry if Retry-After header is greater than maxRetryAfter', async t => {
+	const {retryCount} = await got(`${s.url}/413`, {
+		retry: {maxRetryAfter: 1000},
+		throwHttpErrors: false
+	});
+	t.is(retryCount, 0);
+});
+
 test.after('cleanup', async () => {
 	await s.close();
 });
diff --git a/test/stream.js b/test/stream.js
index 927dcd4..54b7b81 100644
--- a/test/stream.js
+++ b/test/stream.js
@@ -12,6 +12,9 @@ test.before('setup', async () => {
 	s = await createServer();
 
 	s.on('/', (req, res) => {
+		res.writeHead(200, {
+			unicorn: 'rainbow'
+		});
 		res.end('ok');
 	});
 
@@ -75,12 +78,12 @@ test('have response event', async t => {
 });
 
 test('have error event', async t => {
-	const stream = got.stream(`${s.url}/error`, {retries: 0});
+	const stream = got.stream(`${s.url}/error`, {retry: 0});
 	await t.throws(pEvent(stream, 'response'), /Response code 404 \(Not Found\)/);
 });
 
 test('have error event #2', async t => {
-	const stream = got.stream('.com', {retries: 0});
+	const stream = got.stream('.com', {retry: 0});
 	await t.throws(pEvent(stream, 'response'), /getaddrinfo ENOTFOUND/);
 });
 
@@ -111,6 +114,21 @@ test('piping works', async t => {
 	t.is(await getStream(got.stream(`${s.url}/`).on('error', () => {})), 'ok');
 });
 
+test('proxying headers works', async t => {
+	const server = await createServer();
+
+	server.on('/', (req, res) => {
+		got.stream(s.url).pipe(res);
+	});
+
+	await server.listen(server.port);
+
+	const {headers} = await got(server.url);
+	t.is(headers.unicorn, 'rainbow');
+
+	await server.close();
+});
+
 test.after('cleanup', async () => {
 	await s.close();
 });
diff --git a/test/timeout.js b/test/timeout.js
index 9abda99..03de280 100644
--- a/test/timeout.js
+++ b/test/timeout.js
@@ -5,7 +5,7 @@ import got from '../source';
 import {createServer} from './helpers/server';
 
 let s;
-const reqDelay = 80;
+const reqDelay = 160;
 
 test.before('setup', async () => {
 	s = await createServer();
@@ -23,7 +23,7 @@ test('timeout option (ETIMEDOUT)', async t => {
 	await t.throws(
 		got(s.url, {
 			timeout: 0,
-			retries: 0
+			retry: 0
 		}),
 		{
 			code: 'ETIMEDOUT'
@@ -35,7 +35,7 @@ test('timeout option (ESOCKETTIMEDOUT)', async t => {
 	await t.throws(
 		got(s.url, {
 			timeout: reqDelay,
-			retries: 0
+			retry: 0
 		}),
 		{
 			code: 'ESOCKETTIMEDOUT'
@@ -47,7 +47,7 @@ test('timeout option as object (ETIMEDOUT)', async t => {
 	await t.throws(
 		got(s.url, {
 			timeout: {socket: reqDelay * 2.5, request: 0},
-			retries: 0
+			retry: 0
 		}),
 		{
 			code: 'ETIMEDOUT'
@@ -59,7 +59,7 @@ test('timeout option as object (ESOCKETTIMEDOUT)', async t => {
 	await t.throws(
 		got(s.url, {
 			timeout: {socket: reqDelay * 1.5, request: reqDelay},
-			retries: 0
+			retry: 0
 		}),
 		{
 			code: 'ESOCKETTIMEDOUT'
@@ -71,7 +71,7 @@ test('socket timeout', async t => {
 	await t.throws(
 		got(s.url, {
 			timeout: {socket: reqDelay / 20},
-			retries: 0
+			retry: 0
 		}),
 		{
 			code: 'ESOCKETTIMEDOUT'
@@ -85,7 +85,7 @@ test('request timeout', async t => {
 	await t.throws(
 		got(s.url, {
 			timeout: {request: reqDelay},
-			retries: 0
+			retry: 0
 		}),
 		{
 			code: 'ESOCKETTIMEDOUT'
@@ -98,13 +98,15 @@ test('retries on timeout (ESOCKETTIMEDOUT)', async t => {
 
 	await t.throws(got(s.url, {
 		timeout: reqDelay,
-		retries: () => {
-			if (tried) {
-				return 0;
+		retry: {
+			retries: () => {
+				if (tried) {
+					return 0;
+				}
+
+				tried = true;
+				return 1;
 			}
-
-			tried = true;
-			return 1;
 		}
 	}), {
 		code: 'ESOCKETTIMEDOUT'
@@ -118,13 +120,15 @@ test('retries on timeout (ETIMEDOUT)', async t => {
 
 	await t.throws(got(s.url, {
 		timeout: 0,
-		retries: () => {
-			if (tried) {
-				return 0;
+		retry: {
+			retries: () => {
+				if (tried) {
+					return 0;
+				}
+
+				tried = true;
+				return 1;
 			}
-
-			tried = true;
-			return 1;
 		}
 	}), {code: 'ETIMEDOUT'});
 
@@ -132,6 +136,9 @@ test('retries on timeout (ETIMEDOUT)', async t => {
 });
 
 test('timeout with streams', async t => {
-	const stream = got.stream(s.url, {timeout: 0, retries: 0});
+	const stream = got.stream(s.url, {
+		timeout: 0,
+		retry: 0
+	});
 	await t.throws(pEvent(stream, 'response'), {code: 'ETIMEDOUT'});
 });
diff --git a/test/url-to-options.js b/test/url-to-options.js
index 13890ad..e301826 100644
--- a/test/url-to-options.js
+++ b/test/url-to-options.js
@@ -2,9 +2,8 @@ import url from 'url';
 import test from 'ava';
 import urlToOptions from '../source/url-to-options';
 
-const exampleURL = 'https://user:password@github.com:443/say?hello=world#bang';
-
 test('converts node legacy URL to options', t => {
+	const exampleURL = 'https://user:password@github.com:443/say?hello=world#bang';
 	const parsedURL = url.parse(exampleURL);
 	const options = urlToOptions(parsedURL);
 	const expected = {
@@ -22,6 +21,7 @@ test('converts node legacy URL to options', t => {
 });
 
 test('converts URL to options', t => {
+	const exampleURL = 'https://user:password@github.com:443/say?hello=world#bang';
 	// TODO: Use the `URL` global when targeting Node.js 10
 	const parsedURL = new url.URL(exampleURL);
 	const options = urlToOptions(parsedURL);
@@ -74,3 +74,43 @@ test('only adds port to options for URLs with ports', t => {
 	t.deepEqual(options, expected);
 	t.false(Reflect.has(options, 'port'));
 });
+
+test('does not concat null search to path', t => {
+	const exampleURL = 'https://github.com/';
+	const parsedURL = url.parse(exampleURL);
+
+	t.is(parsedURL.search, null);
+
+	const options = urlToOptions(parsedURL);
+	const expected = {
+		hash: null,
+		hostname: 'github.com',
+		href: 'https://github.com/',
+		path: '/',
+		pathname: '/',
+		protocol: 'https:',
+		search: null
+	};
+
+	t.deepEqual(options, expected);
+});
+
+test('does not add null port to options', t => {
+	const exampleURL = 'https://github.com/';
+	const parsedURL = url.parse(exampleURL);
+
+	t.is(parsedURL.port, null);
+
+	const options = urlToOptions(parsedURL);
+	const expected = {
+		hash: null,
+		hostname: 'github.com',
+		href: 'https://github.com/',
+		path: '/',
+		pathname: '/',
+		protocol: 'https:',
+		search: null
+	};
+
+	t.deepEqual(options, expected);
+});