-
-
Notifications
You must be signed in to change notification settings - Fork 127
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
fix(ClientRequest): throw when writing after end for bypass responses #462
Conversation
@@ -315,3 +315,26 @@ it('does not send request body to the original server given mocked response', as | |||
const text = await getIncomingMessageBody(response) | |||
expect(text).toBe('mock created!') | |||
}) | |||
|
|||
it.only('emits the ERR_STREAM_WRITE_AFTER_END error when write after end given no mocked response', async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this should be a compliance test in test/modules/http/compliance
where we will also feature the real usage of http.*
instead of constructing the internal class.
So, do I understand the root cause of this issue correctly:
// Although request was ended, the "until()" closure may
// still be running.
req.end()
// Which allows this to be called without Node throwing an error
// because "end" hasn't really finished yet (it's async).
req.write('should throw') Is that about right, @mikicho? I wonder if we can solve this by marking the request as sent immediately in A bit of history: we buffer the With that in mind, we cannot really mark the request as finished in Interceptors are a bit unlike other request intercepting solutions because they don't know what requests should be mocked until requests happen (no request matching built-in). That's an intentional design choice to allow for "transparent" interception but it comes with a cost, like the async nature of the |
@@ -99,6 +99,11 @@ export class NodeClientRequest extends ClientRequest { | |||
} | |||
|
|||
write(...args: ClientRequestWriteArgs): boolean { | |||
// Fallback to native behavior to throw | |||
if (this.destroyed || this.finished) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe neither of these properties will be set immediately after calling .end()
for bypassed requests because they are set by Node in super.end()
which is called asynchronously.
Given the current limitations, I think the most straightforward way to fix this is to have an internal this.requestSent
boolean flag that will control whether .write()
calls should be buffered anymore. We can set that flag to true
immediately in .end()
regardless of the request listener lookup result.
write(...) {
if (this.requestSent) {
throwNodeWriteAfterEndError()
}
}
end(...) {
// Mark the request as sent immediately so
// the interceptor would know to stop buffering
// ".write()" calls after this point: that's a no-op.
this.requestSent = true
}
What do you think about this?
I dislike introducing any new state but I don't suppose we can rely on request properties since those we need are set async anyway:
finished
is set byrespondWith()
in case of mock responses.finished
is set by Node bysuper.end()
in case of bypassed responses.
And both happen asynchronously after the request listener promise settles.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree! I dislike this as well.
We can alternate the finished
property:
end(..) {
this.finished = true
until(...).then(() => {
this.finished = false
})
}
But I think this is more confusing and error-prone than a new state.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alas, we can't finished
means request body has been sent. In the case of original requests, calling .end()
doesn't mean that the request body has been sent—it will start sending in the .end()
. We do have to rely on an internal custom flag for this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And we'd likely have to recreate the original Node.js error here because calling super.write()
in this case won't produce the "can't write after end error". It will very likely produce something connection-related because the connection has failed to establish.
It shouldn't be hard replicating the Node's native error.
Exactly!
What about manually setting the flags immediately and then unsetting them before the passthrough if the request is unmocked? This is more error-prone but should be maintained with good tests.
Yeah, I started to think about this, and then you came up with an answer! This is a real challenge indeed!
Interesting, what do you mean by transparent interception? Without mocking (responseWith) for logging/recording/etc? Sharing my thoughts... the current implementation answers two questions at once:
But AFAIU it, nothing bound us to separate this question to sync and async phases. Think about something like: interceptor.on('request', ({ request, respondWith }) => {
respondWith(async () => {
return await getCustomResponse(request)
})
console.log(request)
return isMocked(request) // this must be sync
}) class NodeRequestClient {
...
end() {
const isMocked = callRequestHandler({ request, respondWith })
if (isMocked) {
await respondWith.resolve() // or something like that
this.finished = true
... more mocking job
} else {
super.end()
}
}
} Such implementation would significantly reduce the complexity because we won't need to buffer the writes, catch errors, and set/unset flags. Is there a key component I'm missing? (I have a feeling I do 😅) |
I don't think we need to go that far. Just setting
True, this one of the differences between Nock and Interceptors. I still like the asynchronous nature of request listeners because they allow you reading the request body and basing your mocking logic around that. Reading request body is async regardless of how you represent the request, so I can only assume it's impossible to have request body-based mocks in Nock.
Yes! I mean that you can plug in Interceptors and they won't affect the traffic in any way by default, they will be passthrough. You can still fire side-effects, like logging/monitoring/flushing the network to a HAR, but the interception is "transparent". Perhaps not the best word but I lack the one better to describe this behavior.
We effectively introduce a duplicate state this way: having to 1) return a mocked response; 2) mark the request listener as the one returning a mocked response. I find it an inefficient API design if you have to declare your intention multiple times. With the current implementation, you have one way to state that you wish to return a mocked response: call the Also consider the cases when the consumer has to perform an async action, like reading the request's body, to even know if there's a mock for the response.
Complexity for us. But we have to approach it the other way around: reducing the complexity for the user. There's nothing wrong with internal complexity. In fact, most tools are a significant degree more complex and convoluted when it comes to API mocking. We are still threading on a relevantly reasonable abstraction here. |
I was wrong. Nock has body filtering.
I agree. I'm aiming for a win-win situation Anyway, this is out-of-scope. I'll think about it and let you know if I have a more concrete idea. interceptor.on('request', async ({ request , respondWith }) => {
console.log(await something(request)) // the hook still can be async, but we don't await for it.
respondWith(async () => {
return await getCustomResponse(request)
})
}) class NodeRequestClient {
...
end() {
callRequestHandler({ request, respondWith })
if(respondWith.getCalled()) {
await respondWith.resolve() // or something like that
this.finished = true
... more mocking job
} else {
super.end()
}
}
} |
@kettanaito Fixed. PTAL |
@@ -104,6 +105,13 @@ export class NodeClientRequest extends ClientRequest { | |||
} | |||
|
|||
write(...args: ClientRequestWriteArgs): boolean { | |||
if (this.isRequestSent) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's update this after #453 is merged to rely on the new this.state
here:
if (this.state >= HttpRequestInternalState.Sent) {
// Throw an error if attempting to write
// after the request has been sent.
}
Let me know if you need any help updating these changes. I believe they are almost done. Looking forward to having this merged! |
@kettanaito Sure. I'll update this tomorrow. |
@kettanaito fixed this reply. It has been a while, so could you please review this PR to ensure that we haven't missed anything? Thank you! |
I think this also closes #460 |
I added a failing test.
@kettanaito The current implementation of the
end
function is async (unlike in Node), which forces us to setthis.finish = true
in the "sync" part of it (before thereturn this
at the end of the function) to block further writes and re-set it to false before thepassthrough
function if no mock is provided.Maybe related to #346
WDYT?