Skip to content

fix(ClientRequest): support 100 continue flow - #599

Draft
mikicho wants to merge 5 commits into
mainfrom
Michael/fix-100-contrinue
Draft

fix(ClientRequest): support 100 continue flow#599
mikicho wants to merge 5 commits into
mainfrom
Michael/fix-100-contrinue

Conversation

@mikicho

@mikicho mikicho commented Jul 6, 2024

Copy link
Copy Markdown
Member

@mikicho mikicho mentioned this pull request Jul 6, 2024
19 tasks
@HonzaMac

Copy link
Copy Markdown

For mine use case, this error occur when using localstack and calling S3 upload. Uploading for first byte take so long (timeouts), that localstack sends back 100. 100 is not handled by undici/native fetch in v20 correctly and it throws error of not handle status code by undici.

Comment thread test/modules/http/request/http-request-continue.test.ts Outdated
@kettanaito

kettanaito commented Sep 8, 2024

Copy link
Copy Markdown
Member

I've pushed the fix where we are now able to construct a 100 Continue response without it throwing (<199 are not user-configurable status codes).

Updates the tests, no idea why they are failing only when (1) the interceptor is on; (2) the request.end() is nested in the continue event callback. Sharing the log outputs for two scenarios (bypass and mocked)

Bypass

SOCKET WRITE POST /resource HTTP/1.1
expect: 100-continue
Host: 127.0.0.1:62344
Connection: close
Transfer-Encoding: chunked


SOCKET EMIT [ 'resume' ]

stdout | modules/http/compliance/http-request-continue.test.ts > emits "continue" event for a request with "100-continue" expect header
SOCKET EMIT [ 'connect' ]
SOCKET CONNECT
SOCKET EMIT [ 'ready' ]
!!![server] added req.on(data)
SOCKET PUSH HTTP/1.1 100 Continue


SOCKET EMIT [ 'data', 'HTTP/1.1 100 Continue\r\n\r\n' ]
REQ CONTINUE
REQ END
!!!! writing request...
SOCKET WRITE 5
SOCKET WRITE 

SOCKET WRITE hello
SOCKET WRITE 

SOCKET WRITE 0


REQ FINISH

[server] req data: hello

SOCKET PUSH HTTP/1.1 200 OK
X-Powered-By: Express
Date: Sun, 08 Sep 2024 14:59:48 GMT
Connection: close
Transfer-Encoding: chunked

5
hello
0


SOCKET EMIT [
  'data',
  'HTTP/1.1 200 OK\r\n' +
    'X-Powered-By: Express\r\n' +
    'Date: Sun, 08 Sep 2024 14:59:48 GMT\r\n' +
    'Connection: close\r\n' +
    'Transfer-Encoding: chunked\r\n' +
    '\r\n' +
    '5\r\n' +
    'hello\r\n' +
    '0\r\n' +
    '\r\n'
]
REQ RESPONSE

SOCKET FINISH

SOCKET EMIT [ 'prefinish' ]
SOCKET EMIT [ 'finish' ]
SOCKET EMIT [ 'close', false ]

SOCKET CLOSE

Mocked

SOCKET WRITE POST /resource HTTP/1.1
expect: 100-continue
Host: 127.0.0.1:62777
Connection: close
Transfer-Encoding: chunked


[*] request POST http://127.0.0.1:62777/resource
SOCKET EMIT [ 'resume' ]
SOCKET EMIT [ 'resume' ]
SOCKET EMIT [ 'connect' ]
SOCKET CONNECT
SOCKET EMIT [ 'ready' ]
!!![server] added req.on(data)

SOCKET PUSH HTTP/1.1 100 Continue


SOCKET EMIT [ 'data', 'HTTP/1.1 100 Continue\r\n\r\n' ]
REQ CONTINUE
REQ END
!!!! writing request...
SOCKET WRITE 5
SOCKET WRITE 

SOCKET WRITE hello
REQ BODY! hello true
SOCKET WRITE 

SOCKET WRITE 0


REQ FINISH

// HANGS HERE FOR A WHILE UNTIL TEST TIMESOUT
// BECAUSE "req.on(data)" NEVER EMITS, AND THUS
// THE SERVER NEVER SENDS A RESPONSE.

SOCKET END

SOCKET FINISH

SOCKET EMIT [ 'end' ]
SOCKET EMIT [ 'close' ]
SOCKET EMIT [ 'prefinish' ]
SOCKET EMIT [ 'finish' ]
SOCKET EMIT [ 'close', false ]

SOCKET CLOSE
SOCKET CLOSE

@kettanaito kettanaito added the help wanted Extra attention is needed label Sep 8, 2024
@JoaquinFernandez

JoaquinFernandez commented Apr 23, 2025

Copy link
Copy Markdown

I'll post here, since I see it is already in conversation for Nock. This issue is also happening for me, when uploading a big file, the client first sends the Expect: 100 Continue, before sending the body. This is expected behavior from the http lib.

I'd be happy to help implement a solution, since I don't see how I could work around this elsewhere and it is necessary for me to upgrade to nock v14 (with fetch support, thanks to mswjs/interceptors).

@JoaquinFernandez

Copy link
Copy Markdown

Bump

@mikicho

mikicho commented Jun 25, 2025

Copy link
Copy Markdown
Member Author

@kettanaito Maybe things changed since you worked on it, but this is working to me:

const { FetchResponse } = require('./lib/node')
const { ClientRequestInterceptor } = require('./lib/node/interceptors/ClientRequest')
const http = require('http');

const interceptor = new ClientRequestInterceptor();
interceptor.apply()

// Simple HTTP server
const server = http.createServer((req, res) => {
  if (req.method === 'POST') {
    let body = '';
    req.on('data', chunk => (body += chunk));
    req.on('end', () => {
      res.writeHead(200, { 'Content-Type': 'text/plain' });
      res.end('Received: ' + body);
    });
  } else {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Hello World');
  }
});

server.listen(3001, () => {
  console.log('Server listening on http://localhost:3001');
});

// Example client making a request with 'Expect: 100-continue'
const options = {
  port: 3001,
  method: 'POST',
  headers: {
    'Content-Length': Buffer.byteLength('test body'),
    'Expect': '100-continue'
  }
};

const req = http.request(options, res => {
  let data = '';
  res.on('data', chunk => (data += chunk));
  res.on('end', () => {
    console.log('Response:', data);
    server.close();
  });
});

req.on('continue', () => {
  req.write('test body');
  req.end();
});

The problem is if the user wants to return 100 response as mocked response:

interceptor.on('request', ({controller}) => {
  controller.respondWith(new FetchResponse(null, { status: 100 }))
});

error:

Server listening on http://localhost:3001
node:_http_client:542
emitErrorEvent(req, new ConnResetException('socket hang up'));
^

Error: socket hang up
at MockHttpSocket.socketOnEnd (node:_http_client:542:25)
at MockHttpSocket.emit (node:events:530:35)
at
at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
Emitted 'error' event on ClientRequest instance at:
at emitErrorEvent (node:_http_client:104:11)
at MockHttpSocket.socketOnEnd (node:_http_client:542:5)
at MockHttpSocket.emit (node:events:530:35)
at
at process.processTicksAndRejections (node:internal/process/task_queues:105:5) {
code: 'ECONNRESET'
}

I continue (🥁) to investigate it.

@mikicho

mikicho commented Jun 25, 2025

Copy link
Copy Markdown
Member Author

I think the problem is that the request "continues" to send the body, but on the interceptors side, we have already passed the body read phase. So nothing happened.

The socket hang up error is because we end the stream (this.push(null))
Another weird thing I see which may be related, this.shouldKeepAlive is always false even when I send new Agent( { keepAlive: true})


Update: ok... I understand the issue, we get the body (after the continue) in the interceptor, but can't react to it, because we already responded to the request:

interceptor.on('request', ({controller, request}) => {
  request.text().then(body => controller.respondWith(new FetchResponse('Hello World', { status: 200 }))) // body = "test body" as expected
  controller.respondWith(new FetchResponse(null, { status: 100 }))
});

InterceptorError: Failed to respond to the "POST http://localhost:3001/" request: the "request" event has already been handled.

@kettanaito WDYT?

@mikicho

mikicho commented Sep 11, 2025

Copy link
Copy Markdown
Member Author

Note for future: maybe we can use information event: https://github.com/nodejs/node/blob/ac131bdc0155e3e928e781cc16198e883b01bef2/lib/_http_client.js#L718

@JoaquinFernandez

Copy link
Copy Markdown

Hi! Any updates on this? Anyway I can help?

@kettanaito

Copy link
Copy Markdown
Member

Implemented in #770.

@kettanaito kettanaito closed this Jul 22, 2026
@mikicho mikicho reopened this Jul 24, 2026
@JoaquinFernandez

Copy link
Copy Markdown

First of all, thank you for #770. Moving interception down to net.connect()/tls.connect() and patching the TCP/TLS wraps is a much bigger undertaking than patching ClientRequest, and it shows: requests now go through nearly the whole Node networking stack before we touch anything. I know this thread has been open a while, and I appreciate that the fix came as a proper architectural change rather than a patch on top of the old model.

I tested 0.42.3 against the case this PR was opened for, and I think the 100-continue flow is only half covered.

What works now: the request is intercepted, which it wasn't before — that alone is a real step forward.

What still deadlocks: reading the request body inside the request listener. The client withholds the body until it receives 100 Continue, but the interceptor never sends one — so a listener that awaits the body waits forever, and the client never gets to send it.

import http from 'node:http'
import { HttpRequestInterceptor } from '@mswjs/interceptors/http'

const interceptor = new HttpRequestInterceptor()
interceptor.on('request', async ({ request, controller }) => {
  await request.clone().arrayBuffer() // <- never resolves
  controller.respondWith(new Response('mocked'))
})
interceptor.apply()

const body = 'hello'
const request = http.request({
  host: 'example.invalid', // RFC 2606, can never resolve
  port: 80,
  method: 'POST',
  path: '/upload',
  headers: { Expect: '100-continue', 'Content-Length': Buffer.byteLength(body) },
})

request.on('continue', () => request.end(body))
request.on('response', response => console.log('response:', response.statusCode))
request.on('error', error => console.log('error:', error.code))
setTimeout(() => console.log('still pending after 5s'), 5000)

Drop the await request.clone().arrayBuffer() line and the same request resolves with 200. Keep it and nothing happens: the continue event never fires.

@mswjs/interceptors@0.42.3, Node v22.22.1.

Why this still blocks the original use case: nock has to read the body to decide which interceptor matches a request, so it hits exactly this deadlock. That's why it currently passes every Expect: 100-continue request straight through to the network instead (nock/nock#2877). AWS SDK v3 sets that header on its own for uploads, so in practice S3 tests either hang or silently escape the mock.

So while #770 fixed the architecture, consumers that need the request body still can't handle these requests.

Would you accept a change where the interceptor replies 100 Continue automatically as soon as a listener accesses the request body, or would you rather expose it explicitly on the controller (e.g. controller.continue()) and leave the decision to the consumer? Happy to put up a PR for whichever direction you prefer

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

help wanted Extra attention is needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants