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

fix(MockHttpSocket): handle response stream errors #548

Merged

Conversation

kettanaito
Copy link
Member

@kettanaito kettanaito commented Apr 12, 2024

When a mocked response stream receives an error (e.g. controller.error(error)), translate that to request errors (i.e. emit the "error" event and abort the request).

Todo

  • Check how Node.js handles these situations. Do they translate to the "error" event? To error responses?

@kettanaito kettanaito requested a review from mikicho April 12, 2024 20:01
@kettanaito kettanaito changed the base branch from main to feat/yet-another-socket-interceptor April 12, 2024 20:01
@kettanaito
Copy link
Member Author

Alright, so treating those response stream errors as request errors isn't right. Even the original comment says so.

The problem is, I cannot reproduce the error event on response when communicating with a Node.js server. I'm doing this:

function createErrorStream() {
  return new ReadableStream({
    start(controller) {
      controller.enqueue(new TextEncoder().encode('hello'))
      controller.error(new Error('stream error'))
    },
  })
}

const httpServer = new HttpServer((app) => {
  app.get('/resource', (req, res) => {
    res.pipe(Readable.fromWeb(createErrorStream()))
  })
})
  const request = http.get(httpServer.http.url('/resource'))
  request.on('error', requestErrorListener)
  request.on('response', (response) => {
    console.log('RESPONSE!')

    response.on('error', (error) => {
      console.log('ERROR!', error)
    })
  })

The response event is emitted but not the error event on the response.

@mikicho, is this roughly how you are reproducing this in a non-mocked scenario?

// This way, the client still receives the "response" event,
// and the actual stream error is forwarded as the "error"
// event on the http.IncomingMessage instance.
flushHeaders()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will emit the response event on the client because it will receive the response headers.


// Forward response streaming errors as response errors.
/** @todo This doesn't quite do it. */
// this.destroy(error)
Copy link
Member Author

@kettanaito kettanaito Apr 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs help

I'm struggling to find a way to trigger this:

https://github.com/nodejs/node/blob/3a456c6db802b5b25594d3a9d235d4989e9f7829/lib/_http_incoming.js#L239

The error event on http.IncomingMessage is emitted when the socket is destroyed (implemented in the ._destroy() method). For some reason, calling this.destroy() on the HttpMockSocket doesn't trigger that.

if (httpHeaders.length > 0) {
flushHeaders()
}
flushHeaders()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's safe to call flushHeaders() because it will do nothing if the headers have already been flushed.

@kettanaito
Copy link
Member Author

How this works in non-mocked world

When there's a response stream error, the server throws an error and, usually, handles it as a 500 response:

response 500 Internal Server Error
RESPONSE DATA <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Error: Cannot pipe, not readable<br>

This means that erroring the response stream just results in an error thrown. It doesn't get forwarded to ClientRequest or IncomingMessage in any special way, aside from a 500 response arriving to the client.

@mikicho, is this the same expectation Nock has in this scenario?

@kettanaito
Copy link
Member Author

This should be fixed by #532 but this PR still contains viable tests and the fix (flushing response headers on unhandled response stream errors).

@kettanaito
Copy link
Member Author

@mikicho, I think this is done! Response stream errors are now handled as 500 error responses. I believe that's what's happening in the actual server as well.

}
} catch (error) {
if (error instanceof Error) {
Copy link
Contributor

@mikicho mikicho Apr 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not a blocker, but I think we need to return 500 for any error.
The current implementation swallows the error from the user in case it does not extend Error

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I completely agree! I revisited by previous point and found it wrong. I'm alining the uncaught exceptions handling in #555, and then will update this branch to migrate the implementation from NodeClientRequest.ts.

@mikicho
Copy link
Contributor

mikicho commented Apr 16, 2024

Is this the same expectation Nock has in this scenario?

No, Nock propagates the error event to the response (IncmingMessage).
I believe the scenario you described in here may not cover the entire picture because Node itself could emit an error for the underlying readable.

I think Nock's behavior provides better DX and is more predictable from the user's POV.

@kettanaito
Copy link
Member Author

I think Nock's behavior provides better DX and is more predictable from the user's POV.

While Nock compatibility is the goal, we should focus on how Node.js behaves here, not Nock. From everything I've tried so far, Node doesn't handle response stream errors in any special way. Errors via controller.error(e) simply throw because by design, they meant to error if you try to operate with the stream afterward.

When piping a regular Readable, the source can destroy the stream, and that will propagate as the error event of IncomingMessage. That looks like a different use case though, and we should handle it as such. Perhaps the confusion here is caused by the fact that ReadableStream is not entirely the same behavior-wise as Readable, although from the source, it seems controller.error(e) should destroy Readable:

@kettanaito
Copy link
Member Author

kettanaito commented Apr 17, 2024

One more argument to preserving non-error exceptions is you can throw a Response instance and that will be used as the mocked response: https://github.com/mswjs/msw/blob/43a163b2aab87d124aecde05826729b896ad3484/test/node/rest-api/response/throw-response.node.test.ts#L26.

This is an intentional design to support Remix-like response throwing.

I've added this intention as automated tests in #553.

// Coerce response stream errors to 500 responses.
// Don't flush the original response headers because
// unhandled errors translate to 500 error responses forcefully.
this.respondWith(createServerErrorResponse(error))
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Treating all the response stream errors as 500 error responses! Consistency.

@kettanaito
Copy link
Member Author

I will merge this one. If I missed anything, please let me know, we can iterate on it in the follow-up pull request. Thanks!

@kettanaito kettanaito merged commit 085d1ec into feat/yet-another-socket-interceptor Apr 17, 2024
2 checks passed
@kettanaito kettanaito deleted the fix/forward-stream-error branch April 17, 2024 16:47
@mikicho
Copy link
Contributor

mikicho commented Apr 17, 2024

While Nock compatibility is the goal, we should focus on how Node.js behaves here...

@kettanaito I don't think it is not a question of compatibility. The scenario, as I see it, is in-process Readable failure, not server-side failure, and in Node.js, probably extremely rare (I can't even think of such a case)

This is why I believe the current behavior is not quite right and could be causing some confusion. We take a user-side error and convert it to 500 error although the "server" was ok.
In other words, one can ask when the user would expect us to emit an error event for the response object:

http.get(..., res => { 
  res.on('error', () => {} 
}

@kettanaito
Copy link
Member Author

kettanaito commented Apr 17, 2024

We take a user-side error and convert it to 500 error although the "server" was ok.

But that's the point, it wasn't. When mocking, your request listener is the server. If there's an exception there, it's equivalent to the actual server getting an exception on its runtime, which is often handled as a 500 error response.

Note that this has no effect on the bypassed requests.

In other words, one can ask when the user would expect us to emit an error event for the response object:

Technically, when the response stream is destroyed. That's the only occasion per spec when IncomingMessage emits the error event. Calling controller.error(e), somehow, doesn't trigger the stream's destruction.

Here's the only scenario where I think the error will be emitted correctly:

http.get(url, (res) => {
  res.on('error', () => console.log('Error!'))
  res.destroy(new Error('Do not need this anymore'))
})

Destroying the IncomingMessage stream directly will emit the error event. But since you don't have access to the IncomingMessage in the request listener, you can't destroy the response that way.

@mikicho
Copy link
Contributor

mikicho commented Apr 17, 2024

When mocking, your request listener is the server

While this is true, in this case, I see the error as coming from Node.js itself, not from the server. I think it would provide better DX.
Anyway, I think this is not a blocker, and we should release it and see what the users think.

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

Successfully merging this pull request may close these issues.

None yet

2 participants