Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Internal "options.timeout" is incompatible with the "timeout" option of "http.ClientRequest" #1579

Closed
2 tasks done
kettanaito opened this issue Jan 12, 2021 · 12 comments
Closed
2 tasks done
Assignees
Labels
external The issue related to an external project

Comments

@kettanaito
Copy link

kettanaito commented Jan 12, 2021

Describe the bug

  • Node.js version: v12.18.0
  • OS & version: MacOS Catalina (0.15.5 (19F101))

Actual behavior

Hey 👋 I'm one of the maintainers of MSW, an API mocking library. Some of our users have reported a type error when using MSW and Got for requests interception in NodeJS (see mswjs/interceptors#86). I've investigated the issue and tracked it down to got source code, with which I'd kindly ask for your help.

The internal options.timeout seems to represent a map of differents timeouts ("request", "socket", "lookup", "connect", etc.), which can be observed in how that option is handled when it's passed to the timed-out.js utility as the delays argument.

Here's the journey of the options.timeout property:

timeout: {},

During the _makeRequest function the timeout option gets deleted and restored:

got/source/core/index.ts

Lines 2421 to 2424 in 8b88be2

// Restore options
options.request = request;
options.timeout = timeout;
options.agent = agent;

got/source/core/index.ts

Lines 2364 to 2366 in 8b88be2

// TODO: Fix this ignore.
// @ts-expect-error
delete options.timeout;

Eventually, it gets passed to the timed-out.js:

this[kCancelTimeouts] = timedOut(request, timeout, url);

And handled depending on what keys options.timeout has:

if (typeof delays.request !== 'undefined') {
addTimeout(delays.request, timeoutHandler, 'request');
}
if (typeof delays.socket !== 'undefined') {

options.timeout becomes delays.

I don't yet understand why, but the internal options object of got is passed to the ClientRequest constructor at some point of time. I can see that because options object of our internal request interception library changes its options.timeout value from undefined to {} (set by got) during runtime:

https://github.com/mswjs/node-request-interceptor/blob/2f5a6458f41ce1b3e83eabc15a59778579d8155d/src/interceptors/ClientRequest/ClientRequestOverride.ts#L35

When ClientRequest is constructed with{ timeout: {} } option that causes a type error:

TypeError [ERR_INVALID_ARG_TYPE]: The "timeout" argument must be of type number. Received an instance of Object
    at validateNumber (internal/validators.js:125:11)
    at getTimerDuration (internal/timers.js:376:3)
    at new ClientRequest (_http_client.js:167:20)
    at request (http.js:46:10)

That behavior is according to NodeJS spec, as options.timeout must be a number:

timeout <number>: A number specifying the socket timeout in milliseconds. This will set the timeout before the socket is connected.
https://nodejs.org/docs/latest-v12.x/api/http.html#http_http_request_options_callback

Expected behavior

I haven't dug that deep into got, but I suspect that it uses the internal options object to construct a ClientRequest instance. If that's the case, I think the options.timeout property should be renamed to represent the internal timeouts map, and not the ClientRequest's timeout options per specification.

I'd be thankful for your assistance on this.

Code to reproduce

Checklist

  • I have read the documentation.
  • I have tried my code with the latest version of Node.js and Got.
@szmarczak szmarczak added the bug Something does not work as it should label Jan 12, 2021
@szmarczak szmarczak self-assigned this Jan 12, 2021
@szmarczak szmarczak added external The issue related to an external project and removed bug Something does not work as it should labels Jan 12, 2021
@szmarczak
Copy link
Collaborator

Looks like a race condition in your project. requestOptions.timeout is undefined when running

console.log(requestOptions.timeout);

right before

let requestOrResponse = await fn(url, requestOptions);

szm@solus ~/Desktop/got $ npm run build && node demo.js

> got@11.8.0 build
> del-cli dist && tsc

undefined
node:internal/process/promises:225
          triggerUncaughtException(err, true /* fromPromise */);
          ^

TypeError [ERR_INVALID_ARG_TYPE]: The "timeout" argument must be of type number. Received an instance of Object
    at new NodeError (node:internal/errors:278:15)
    at validateNumber (node:internal/validators:128:11)
    at getTimerDuration (node:internal/timers:383:3)
    at new ClientRequest (node:_http_client:173:20)
    at request (node:http:50:10)
    at Object.proxiedOriginalRequest (/home/szm/Desktop/got/node_modules/node-request-interceptor/lib/interceptors/ClientRequest/index.js:55:36)
    at ClientRequestOverride.<anonymous> (/home/szm/Desktop/got/node_modules/node-request-interceptor/lib/interceptors/ClientRequest/ClientRequestOverride.js:249:39)
    at step (/home/szm/Desktop/got/node_modules/node-request-interceptor/lib/interceptors/ClientRequest/ClientRequestOverride.js:33:23)
    at Object.next (/home/szm/Desktop/got/node_modules/node-request-interceptor/lib/interceptors/ClientRequest/ClientRequestOverride.js:14:53)
    at fulfilled (/home/szm/Desktop/got/node_modules/node-request-interceptor/lib/interceptors/ClientRequest/ClientRequestOverride.js:5:58) {
  code: 'ERR_INVALID_ARG_TYPE'
}

@szmarczak
Copy link
Collaborator

That's right. If I remove

options.timeout = timeout;

and make delays default to {}

export default (request: ClientRequest, delays: Delays, options: TimedOutOptions): () => void => {

then I get

error RequestError: connect ECONNREFUSED 127.0.0.1:80

which assume is expected.

@szmarczak
Copy link
Collaborator

I guess you don't clone the options, therefore modifying them results in an unexpected error. No such thing happens when running native Node.js.

@szmarczak
Copy link
Collaborator

The culprit is here. You forgot to clone the options.

https://github.com/mswjs/node-request-interceptor/blob/2f5a6458f41ce1b3e83eabc15a59778579d8155d/src/interceptors/ClientRequest/utils/normalizeHttpRequestParams.ts#L66

@kettanaito
Copy link
Author

kettanaito commented Jan 13, 2021

Thank you for the suggestions, @szmarczak! Cloning the request options indeed helped.

The behavior after cloning them, although doesn't involve options.timeout anymore, isn't valid. This exception is not expected:

error RequestError: connect ECONNREFUSED 127.0.0.1:80

Somewhere along with the request life-cycle, there's a request to 127.0.0.1:80, although it's never described explicitly. The code is making a request to http://localhost:<RANDOM_PORT> and I can confirm that request options describe that URL correctly.

If I console.log(options.url) in _makeRequest, I see the correct URL: http://localhost:54765/user. Nevertheless, the exception is still thrown. If I swap got with node-fetch without any extra change the exception is gone and the request is correctly made to the test server on the right port.

+import fetch from 'node-fetch'

-got(server.makeHttpUrl('/user'))
+fetch(server.makeHttpUrl('/user'))

server.makeHttpUrl returns a string that represents a URL relative to the spawned test server.

Can it be that got performs an intermediate request to :80 for some reason?

@szmarczak szmarczak reopened this Jan 13, 2021
@Giotino
Copy link
Collaborator

Giotino commented Jan 13, 2021

Your repo name is a red flag here: mswjs/node-request-interceptor

Are you modifying the default Node.JS request function (or Node.JS HTTP Agent)?

@Giotino
Copy link
Collaborator

Giotino commented Jan 13, 2021

I think I found the answer I was looking for

This library monkey-patches the following native modules:

http.get/http.request
https.get/https.request

https://github.com/mswjs/node-request-interceptor/blob/master/src/interceptors/ClientRequest/ClientRequestOverride.ts

@kettanaito
Copy link
Author

kettanaito commented Jan 13, 2021

@Giotino, I believe you've found the correct answer. That repository is a library that provisions request interception in NodeJS. It works similarly to Nock, only with the inversed control over the mocked responses. I wish there was a better way to intercept requests in Node, but monkey-patching native modules is the only way there is now.

That being said, we model the intercepted behavior as close to the original one as possible, diving into the source code ofhttp.ClientRequest and replicating that to the necessary degree. We also don't override each and every aspect of the native modules, only the parts necessary to enable the interception.

Modification of the native modules doesn't seem to be affecting anything for this issue: as I've mentioned above, the code works fine using node-fetch or plain http.ClientRequest. It makes a logical assumption that unexpected behavior (not necessarily the root cause) is related to got, so I'm reaching for help.

@szmarczak
Copy link
Collaborator

mswjs/interceptors#87 (comment)

@szmarczak
Copy link
Collaborator

@sindresorhus @Giotino I have investigated and it turns out the bug is on the interceptor side.

@kettanaito
Copy link
Author

Once more huge thanks to @szmarczak. The issue has been investigated and resolved. No changes on got side are necessary, entirely scoped to the interceptor.

@Drarig29
Copy link

For future reference, the delete options.timeout; line was removed in #1667, which was released in Got 12.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
external The issue related to an external project
Projects
None yet
Development

No branches or pull requests

4 participants