Repository: nodejs/node
Pull request: http: add Agent.agentKeepAliveTimeoutBuffer option

This introduces a new agentKeepAliveTimeoutBuffer option for http.Agent.
The agent uses this buffer to adjust the effective timeout when interpreting the Keep-Alive: timeout=N header sent by the server:

effectiveTimeout = (timeout * 1000) - agentKeepAliveTimeoutBuffer

This helps prevent premature socket closure by giving clients a safety margin before server-side timeouts.

Added: agentKeepAliveTimeoutBuffer option (default: 1000ms)
Follows up on: TODO(jazelly): make this configurable
Applies fallback for invalid values
Updates documentation (http.md)
Adds test: test-http-agent-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: @@ -116,6 +116,10 @@ http.get({
 <!-- YAML
 added: v0.3.4
 changes:
+  - version:
+    - REPLACEME
+    pr-url: https://github.com/nodejs/node/pull/59315
+    description: Add support for `agentKeepAliveTimeoutBuffer`.
   - version:
     - v24.5.0
     pr-url: https://github.com/nodejs/node/pull/58980
@@ -156,6 +160,12 @@ changes:
     the [initial delay][]
     for TCP Keep-Alive packets. Ignored when the
     `keepAlive` option is `false` or `undefined`. **Default:** `1000`.
+  * `agentKeepAliveTimeoutBuffer` {number} Milliseconds to subtract from
+    the server-provided `keep-alive: timeout=...` hint when determining socket
+    expiration time. This buffer helps ensure the agent closes the socket
+    slightly before the server does, reducing the chance of sending a request
+    on a socket that’s about to be closed by the server.
+    **Default:** `1000`.
   * `maxSockets` {number} Maximum number of sockets to allow per host.
     If the same host opens multiple concurrent connections, each request
     will use new socket until the `maxSockets` value is reached.

filename: lib/_http_agent.js
patch: @@ -22,6 +22,7 @@
 'use strict';
 
 const {
+  NumberIsFinite,
   NumberParseInt,
   ObjectKeys,
   ObjectSetPrototypeOf,
@@ -60,8 +61,6 @@ const kOnKeylog = Symbol('onkeylog');
 const kRequestOptions = Symbol('requestOptions');
 const kRequestAsyncResource = Symbol('requestAsyncResource');
 
-// TODO(jazelly): make this configurable
-const HTTP_AGENT_KEEP_ALIVE_TIMEOUT_BUFFER = 1000;
 // New Agent code.
 
 // The largest departure from the previous implementation is that
@@ -114,6 +113,14 @@ function Agent(options) {
   this.scheduling = this.options.scheduling || 'lifo';
   this.maxTotalSockets = this.options.maxTotalSockets;
   this.totalSocketCount = 0;
+
+  this.agentKeepAliveTimeoutBuffer =
+    typeof this.options.agentKeepAliveTimeoutBuffer === 'number' &&
+    this.options.agentKeepAliveTimeoutBuffer >= 0 &&
+    NumberIsFinite(this.options.agentKeepAliveTimeoutBuffer) ?
+      this.options.agentKeepAliveTimeoutBuffer :
+      1000;
+
   const proxyEnv = this.options.proxyEnv;
   if (typeof proxyEnv === 'object' && proxyEnv !== null) {
     this[kProxyConfig] = parseProxyConfigFromEnv(proxyEnv, this.protocol, this.keepAlive);
@@ -559,7 +566,7 @@ Agent.prototype.keepSocketAlive = function keepSocketAlive(socket) {
       if (hint) {
         // Let the timer expire before the announced timeout to reduce
         // the likelihood of ECONNRESET errors
-        let serverHintTimeout = (NumberParseInt(hint) * 1000) - HTTP_AGENT_KEEP_ALIVE_TIMEOUT_BUFFER;
+        let serverHintTimeout = (NumberParseInt(hint) * 1000) - this.agentKeepAliveTimeoutBuffer;
         serverHintTimeout = serverHintTimeout > 0 ? serverHintTimeout : 0;
         if (serverHintTimeout === 0) {
           // Cannot safely reuse the socket because the server timeout is

filename: test/parallel/test-http-agent-keep-alive-timeout-buffer.js
patch: @@ -0,0 +1,44 @@
+'use strict';
+
+const common = require('../common');
+const assert = require('assert');
+const http = require('http');
+
+// Ensure agentKeepAliveTimeoutBuffer option sets the correct value or falls back to default.
+{
+  const agent1 = new http.Agent({ agentKeepAliveTimeoutBuffer: 1500, keepAlive: true });
+  assert.strictEqual(agent1.agentKeepAliveTimeoutBuffer, 1500);
+
+  const agent2 = new http.Agent({ agentKeepAliveTimeoutBuffer: -100, keepAlive: true });
+  assert.strictEqual(agent2.agentKeepAliveTimeoutBuffer, 1000);
+
+  const agent3 = new http.Agent({ agentKeepAliveTimeoutBuffer: Infinity, keepAlive: true });
+  assert.strictEqual(agent3.agentKeepAliveTimeoutBuffer, 1000);
+
+  const agent4 = new http.Agent({ keepAlive: true });
+  assert.strictEqual(agent4.agentKeepAliveTimeoutBuffer, 1000);
+}
+
+// Integration test with server sending Keep-Alive timeout header.
+{
+  const SERVER_TIMEOUT = 3;
+  const BUFFER = 1500;
+
+  const server = http.createServer((req, res) => {
+    res.setHeader('Keep-Alive', `timeout=${SERVER_TIMEOUT}`);
+    res.end('ok');
+  });
+
+  server.listen(0, common.mustCall(() => {
+    const agent = new http.Agent({ agentKeepAliveTimeoutBuffer: BUFFER, keepAlive: true });
+    assert.strictEqual(agent.agentKeepAliveTimeoutBuffer, BUFFER);
+
+    http.get({ port: server.address().port, agent }, (res) => {
+      res.resume();
+      res.on('end', () => {
+        agent.destroy();
+        server.close();
+      });
+    });
+  }));
+}