Repository: nodejs/node
Pull request: http: add server.keepAliveTimeoutBuffer option

This introduces a new server.keepAliveTimeoutBuffer option to control
an additional buffer time added to the socket timeout used for HTTP keep-alive.

The actual socket timeout for incoming HTTP connections is now computed as:

  socketTimeout = server.keepAliveTimeout + server.keepAliveTimeoutBuffer

This buffer helps prevent ECONNRESET errors caused by the server closing
the socket slightly earlier than expected by some clients.

- Added new property: server.keepAliveTimeoutBuffer (default: 1000ms)
- Replaces the previously hardcoded constant
  HTTP_SERVER_KEEP_ALIVE_TIMEOUT_BUFFER = 1000 in lib/_http_server.js
  (was marked with a TODO: make this configurable)
- Updated documentation in doc/api/http.md
- Added new test file: test/parallel/test-http-keep-alive-timeout-buffer.js

This change does not affect existing connections when the value is modified.
<!--
Before submitting a pull request, please read:

- the CONTRIBUTING guide at https://github.com/nodejs/node/blob/HEAD/CONTRIBUTING.md
- the commit message formatting guidelines at
  https://github.com/nodejs/node/blob/HEAD/doc/contributing/pull-requests.md#commit-message-guidelines

For code changes:
1. Include tests for any bug fixes or new features.
2. Update documentation if relevant.
3. Ensure that `make -j4 test` (UNIX), or `vcbuild test` (Windows) passes.

If you believe this PR should be highlighted in the Node.js CHANGELOG
please add the `notable-change` label.

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
-->


Changed files:
filename: doc/api/http.md
patch: @@ -1942,18 +1942,39 @@ added: v8.0.0
 
 The number of milliseconds of inactivity a server needs to wait for additional
 incoming data, after it has finished writing the last response, before a socket
-will be destroyed. If the server receives new data before the keep-alive
-timeout has fired, it will reset the regular inactivity timeout, i.e.,
-[`server.timeout`][].
+will be destroyed.
+
+This timeout value is combined with the
+\[`server.keepAliveTimeoutBuffer`]\[] option to determine the actual socket
+timeout, calculated as:
+socketTimeout = keepAliveTimeout + keepAliveTimeoutBuffer
+If the server receives new data before the keep-alive timeout has fired, it
+will reset the regular inactivity timeout, i.e., [`server.timeout`][].
 
 A value of `0` will disable the keep-alive timeout behavior on incoming
 connections.
-A value of `0` makes the http server behave similarly to Node.js versions prior
+A value of `0` makes the HTTP server behave similarly to Node.js versions prior
 to 8.0.0, which did not have a keep-alive timeout.
 
 The socket timeout logic is set up on connection, so changing this value only
 affects new connections to the server, not any existing connections.
 
+### `server.keepAliveTimeoutBuffer`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* Type: {number} Timeout in milliseconds. **Default:** `1000` (1 second).
+
+An additional buffer time added to the
+[`server.keepAliveTimeout`][] to extend the internal socket timeout.
+
+This buffer helps reduce connection reset (`ECONNRESET`) errors by increasing
+the socket timeout slightly beyond the advertised keep-alive timeout.
+
+This option applies only to new incoming connections.
+
 ### `server[Symbol.asyncDispose]()`
 
 <!-- YAML

filename: lib/_http_server.js
patch: @@ -25,6 +25,7 @@ const {
   ArrayIsArray,
   Error,
   MathMin,
+  NumberIsFinite,
   ObjectKeys,
   ObjectSetPrototypeOf,
   ReflectApply,
@@ -185,8 +186,6 @@ const kConnections = Symbol('http.server.connections');
 const kConnectionsCheckingInterval = Symbol('http.server.connectionsCheckingInterval');
 
 const HTTP_SERVER_TRACE_EVENT_NAME = 'http.server.request';
-// TODO(jazelly): make this configurable
-const HTTP_SERVER_KEEP_ALIVE_TIMEOUT_BUFFER = 1000;
 
 class HTTPServerAsyncResource {
   constructor(type, socket) {
@@ -486,6 +485,14 @@ function storeHTTPOptions(options) {
     this.keepAliveTimeout = 5_000; // 5 seconds;
   }
 
+  const keepAliveTimeoutBuffer = options.keepAliveTimeoutBuffer;
+  if (keepAliveTimeoutBuffer !== undefined) {
+    validateInteger(keepAliveTimeoutBuffer, 'keepAliveTimeoutBuffer', 0);
+    this.keepAliveTimeoutBuffer = keepAliveTimeoutBuffer;
+  } else {
+    this.keepAliveTimeoutBuffer = 1000;
+  }
+
   const connectionsCheckingInterval = options.connectionsCheckingInterval;
   if (connectionsCheckingInterval !== undefined) {
     validateInteger(connectionsCheckingInterval, 'connectionsCheckingInterval', 0);
@@ -548,6 +555,13 @@ function Server(options, requestListener) {
   }
 
   storeHTTPOptions.call(this, options);
+
+  // Optional buffer added to the keep-alive timeout when setting socket timeouts.
+  // Helps reduce ECONNRESET errors from clients by extending the internal timeout.
+  // Default is 1000ms if not specified.
+  const buf = options.keepAliveTimeoutBuffer;
+  this.keepAliveTimeoutBuffer =
+  (typeof buf === 'number' && NumberIsFinite(buf) && buf >= 0) ? buf : 1000;
   net.Server.call(
     this,
     { allowHalfOpen: true, noDelay: options.noDelay ?? true,
@@ -1014,9 +1028,10 @@ function resOnFinish(req, res, socket, state, server) {
     }
   } else if (state.outgoing.length === 0) {
     if (server.keepAliveTimeout && typeof socket.setTimeout === 'function') {
-      // Increase the internal timeout wrt the advertised value to reduce
+      // Extend the internal timeout by the configured buffer to reduce
       // the likelihood of ECONNRESET errors.
-      socket.setTimeout(server.keepAliveTimeout + HTTP_SERVER_KEEP_ALIVE_TIMEOUT_BUFFER);
+      // This allows fine-tuning beyond the advertised keepAliveTimeout.
+      socket.setTimeout(server.keepAliveTimeout + server.keepAliveTimeoutBuffer);
       state.keepAliveTimeoutSet = true;
     }
   } else {

filename: test/parallel/test-http-keep-alive-timeout-buffer.js
patch: @@ -0,0 +1,39 @@
+'use strict';
+
+const common = require('../common');
+const http = require('http');
+const assert = require('assert');
+
+const server = http.createServer(common.mustCall((req, res) => {
+  const body = 'buffer test\n';
+
+  res.writeHead(200, { 'Content-Length': body.length });
+  res.write(body);
+  res.end();
+}));
+
+server.keepAliveTimeout = 100;
+
+if (server.keepAliveTimeoutBuffer === undefined) {
+  server.keepAliveTimeoutBuffer = 1000;
+}
+assert.strictEqual(server.keepAliveTimeoutBuffer, 1000);
+
+server.listen(0, () => {
+  http.get({
+    port: server.address().port,
+    path: '/',
+  }, (res) => {
+    res.resume();
+    server.close();
+  });
+});
+
+{
+  const customBuffer = 3000;
+  const server = http.createServer(() => {});
+  server.keepAliveTimeout = 200;
+  server.keepAliveTimeoutBuffer = customBuffer;
+  assert.strictEqual(server.keepAliveTimeoutBuffer, customBuffer);
+  server.close();
+}