Repository: kubernetes-client/javascript
Pull request #2367 (2025-04-10 16:07:13)

Reintroduce timeout and keep-alive for watch requests to match client-go
Thanks to @Alabate for opening the initial PR with this fix: https://github.com/kubernetes-client/javascript/pull/2131

This PR includes Alabate's fix + unit test coverage. Below is the original PR description copied here for reference:

---

This will send keep-alive probes to the server every 30 seconds. These features were present prior to the 1.0 but were inadvertently removed.

Fixes https://github.com/kubernetes-client/javascript/issues/2127

Previous relevant issues:

- Initial issue: https://github.com/kubernetes-client/javascript/issues/559
  - PR: https://github.com/kubernetes-client/javascript/pull/630
- Improvement: https://github.com/kubernetes-client/javascript/issues/632
  - PR: https://github.com/kubernetes-client/javascript/pull/635

## Implementation details
Setting `requestInit.timeout = 30000` is equivalent to `socket.setTimeout(30000)` as you can [see in the sources](https://github.com/nodejs/node/blob/main/lib/_http_agent.js#L527).

One of the solution suggested in https://github.com/kubernetes-client/javascript/issues/2127 was to use the keepAlive of the `http.Agent`. But [as documented here](https://nodejs.org/api/http.html#new-agentoptions), that's just a boolean that instruct the agent that we want sockets to be re-used. We want to send packets at a fixed rate to know when a connection is broken. This can be done with the [socket.setKeepAlive() method](https://nodejs.org/api/net.html#socketsetkeepaliveenable-initialdelay), but to use it, we need to access the socket object.

I managed to access the socket and call `setKeepAlive()`, but only after the `await fetch()`, when the response is already arriving. That's the only way I found to access the socket. The Agent create the socket, but [there is no event](https://nodejs.org/api/http.html#class-httpagent) to know when a socket created, you can just access them by list like I did. There is a [socket event on the request object](https://nodejs.org/api/http.html#event-socket), but `node-fetch` abstract it away, and I couldn't find a clean way to access it (https://github.com/node-fetch/node-fetch/discussions/1720 that request this feature).

The only way I've found to get the socket before the request was by monkey-patching the `agent.createConnection()`, but I think this solution is worse.

```ts
const _createConnection = agent.createConnection;
agent.createConnection = function (...args) {
  const socket = _createConnection.apply(agent, args);
  socket.setTimeout(3000);
  socket.setKeepAlive(true, 3000);
  return socket;
}

linux-foundation-easycla[bot]:  <a href="https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/20677673/89748560/2367/#/?version=2"><img src="https://s3.amazonaws.com/cla-project-logo-prod/cla-missing-id.svg" alt="CLA Missing ID" align="left" height="28" width="328"></a> <a href="https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/20677673/89748560/2367/#/?version=2"><img src="https://s3.amazonaws.com/cla-project-logo-prod/cla-not-signed.svg" alt="CLA Not Signed" align="left" height="28" width="328"></a><br/><br /><ul><li> :x: The email address for the commit (a1d444f0582f1cee60d7bdd069b405d64f658177) is not linked to the GitHub account, preventing the EasyCLA check. Consult <a href='https://linuxfoundation.atlassian.net/wiki/spaces/LP/pages/160923756/Missing+ID+on+Commit+but+I+have+an+agreement+on+file' target='_blank'>this Help Article</a> and <a href='https://help.github.com/en/github/committing-changes-to-your-project/why-are-my-commits-linked-to-the-wrong-user' target='_blank'>GitHub Help</a> to resolve. (To view the commit's email address, add .patch at the end of this PR page's URL.) For further assistance with EasyCLA, <a href='https://jira.linuxfoundation.org/servicedesk/customer/portal/4' target='_blank'>please submit a support request ticket</a>.</li></ul><!-- Date Modified: 2025-04-10 16:07:18.075331 -->
k8s-ci-robot: Welcome @rossanthony! <br><br>It looks like this is your first PR to <a href='https://github.com/kubernetes-client/javascript'>kubernetes-client/javascript</a> 🎉. Please refer to our [pull request process documentation](https://git.k8s.io/community/contributors/guide/pull-requests.md) to help your PR have a smooth ride to approval. <br><br>You will be prompted by a bot to use commands during the review process. Do not be afraid to follow the prompts! It is okay to experiment. [Here is the bot commands documentation](https://go.k8s.io/bot-commands). <br><br>You can also check if kubernetes-client/javascript has [its own contribution guidelines](https://github.com/kubernetes-client/javascript/tree/master/CONTRIBUTING.md). <br><br>You may want to refer to our [testing guide](https://git.k8s.io/community/contributors/devel/sig-testing/testing.md) if you run into trouble with your tests not passing. <br><br>If you are having difficulty getting your pull request seen, please follow the [recommended escalation practices](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#why-is-my-pull-request-not-getting-reviewed). Also, for tips and tricks in the contribution process you may want to read the [Kubernetes contributor cheat sheet](https://git.k8s.io/community/contributors/guide/contributor-cheatsheet/README.md). We want to make sure your contribution gets all the attention it needs! <br><br>Thank you, and welcome to Kubernetes. :smiley:
k8s-ci-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-client/javascript/pull/2367#" title="Author self-approved">rossanthony</a>*
**Once this PR has been reviewed and has the lgtm label**, please assign [brendandburns](https://github.com/brendandburns) for approval. For more information see [the Code Review Process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process).

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-client%2Fjavascript).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kubernetes-client/javascript/blob/main/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["brendandburns"]} -->

Patch:
diff --git a/src/watch.ts b/src/watch.ts
index ac842180..702f0f3d 100644
--- a/src/watch.ts
+++ b/src/watch.ts
@@ -41,6 +41,7 @@ export class Watch {
         const controller = new AbortController();
         requestInit.signal = controller.signal as AbortSignal;
         requestInit.method = 'GET';
+        requestInit.timeout = 30000;
 
         let doneCalled: boolean = false;
         const doneCallOnce = (err: any) => {
@@ -54,6 +55,12 @@ export class Watch {
         try {
             const response = await fetch(watchURL, requestInit);
 
+            if (requestInit.agent && typeof requestInit.agent === 'object') {
+                for (const socket of Object.values(requestInit.agent.sockets).flat()) {
+                    socket?.setKeepAlive(true, 30000);
+                }
+            }
+
             if (response.status === 200) {
                 const body = response.body!;
 


Test patch:
diff --git a/src/watch_test.ts b/src/watch_test.ts
index 0e9c65c0..fd2fe60d 100644
--- a/src/watch_test.ts
+++ b/src/watch_test.ts
@@ -167,6 +167,114 @@ describe('Watch', () => {
         stream.destroy();
     });
 
+    it('should call setKeepAlive on the socket to extend the default of 5 mins', async () => {
+        const kc = new KubeConfig();
+
+        const mockSocket = {
+            setKeepAlive: function (enable: boolean, timeout: number) {
+                this.keepAliveEnabled = enable;
+                this.keepAliveTimeout = timeout;
+            },
+            keepAliveEnabled: false,
+            keepAliveTimeout: 0,
+        };
+        Object.assign(kc, {
+            ...fakeConfig,
+            applyToFetchOptions: async () => ({
+                agent: {
+                    sockets: {
+                        'mock-url': [mockSocket],
+                    },
+                },
+            }),
+        });
+        const watch = new Watch(kc);
+
+        const obj1 = {
+            type: 'ADDED',
+            object: {
+                foo: 'bar',
+            },
+        };
+
+        const path = '/some/path/to/object';
+
+        const stream = new PassThrough();
+
+        const [scope] = systemUnderTest();
+
+        let response: IncomingMessage | undefined;
+
+        const s = scope
+            .get(path)
+            .query({
+                watch: 'true',
+                a: 'b',
+            })
+            .reply(200, function (): PassThrough {
+                this.req.on('response', (r) => {
+                    response = r;
+                });
+                stream.push(JSON.stringify(obj1) + '\n');
+                return stream;
+            });
+
+        const receivedTypes: string[] = [];
+        const receivedObjects: string[] = [];
+        let doneCalled = 0;
+        let doneErr: any;
+
+        let handledAllObjectsResolve: any;
+        const handledAllObjectsPromise = new Promise((resolve) => {
+            handledAllObjectsResolve = resolve;
+        });
+
+        let doneResolve: any;
+        const donePromise = new Promise((resolve) => {
+            doneResolve = resolve;
+        });
+
+        await watch.watch(
+            path,
+            {
+                a: 'b',
+            },
+            (phase: string, obj: string) => {
+                receivedTypes.push(phase);
+                receivedObjects.push(obj);
+                if (receivedObjects.length) {
+                    handledAllObjectsResolve();
+                }
+            },
+            (err: any) => {
+                doneCalled += 1;
+                doneErr = err;
+                doneResolve();
+            },
+        );
+
+        await handledAllObjectsPromise;
+
+        deepStrictEqual(receivedTypes, [obj1.type]);
+        deepStrictEqual(receivedObjects, [obj1.object]);
+
+        strictEqual(doneCalled, 0);
+        strictEqual(mockSocket.keepAliveEnabled, true);
+        strictEqual(mockSocket.keepAliveTimeout, 30000);
+
+        const errIn = new Error('err');
+        (response as IncomingMessage).destroy(errIn);
+
+        await donePromise;
+
+        strictEqual(doneCalled, 1);
+        deepStrictEqual(doneErr, errIn);
+
+        s.done();
+
+        stream.destroy();
+    });
+
     it('should handle server errors correctly', async () => {
         const kc = new KubeConfig();
         Object.assign(kc, fakeConfig);