Skip to content

Commit f3d1d33

Browse files
authored
feat(client): expose socketTimeout option (#2965)
The maximum duration (in milliseconds) that the socket can remain idle (i.e., with no data sent or received) before being automatically closed. Default reconnectionStrategy will ignore the new SocketTimeoutError, but users are allowed to have custom strategies wich handle those errors in different ways
1 parent d0a5c4c commit f3d1d33

File tree

4 files changed

+99
-8
lines changed

4 files changed

+99
-8
lines changed

docs/client-configuration.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
| socket.family | `0` | IP Stack version (one of `4 \| 6 \| 0`) |
1010
| socket.path | | Path to the UNIX Socket |
1111
| socket.connectTimeout | `5000` | Connection timeout (in milliseconds) |
12+
| socket.socketTimeout | | The maximum duration (in milliseconds) that the socket can remain idle (i.e., with no data sent or received) before being automatically closed |
1213
| socket.noDelay | `true` | Toggle [`Nagle's algorithm`](https://nodejs.org/api/net.html#net_socket_setnodelay_nodelay) |
1314
| socket.keepAlive | `true` | Toggle [`keep-alive`](https://nodejs.org/api/net.html#socketsetkeepaliveenable-initialdelay) functionality |
1415
| socket.keepAliveInitialDelay | `5000` | If set to a positive number, it sets the initial delay before the first keepalive probe is sent on an idle socket |
@@ -40,7 +41,12 @@ By default the strategy uses exponential backoff, but it can be overwritten like
4041
```javascript
4142
createClient({
4243
socket: {
43-
reconnectStrategy: retries => {
44+
reconnectStrategy: (retries, cause) => {
45+
// By default, do not reconnect on socket timeout.
46+
if (cause instanceof SocketTimeoutError) {
47+
return false;
48+
}
49+
4450
// Generate a random jitter between 0 – 200 ms:
4551
const jitter = Math.floor(Math.random() * 200);
4652
// Delay is an exponential back off, (times^2) * 50 ms, with a maximum value of 2000 ms:

packages/client/lib/client/socket.spec.ts

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@ import { strict as assert } from 'node:assert';
22
import { spy } from 'sinon';
33
import { once } from 'node:events';
44
import RedisSocket, { RedisSocketOptions } from './socket';
5+
import testUtils, { GLOBAL } from '../test-utils';
6+
import { setTimeout } from 'timers/promises';
57

68
describe('Socket', () => {
79
function createSocket(options: RedisSocketOptions): RedisSocket {
8-
const socket = new RedisSocket(
9-
() => Promise.resolve(),
10-
options
11-
);
10+
const socket = new RedisSocket(() => Promise.resolve(), options);
1211

1312
socket.on('error', () => {
1413
// ignore errors
@@ -84,4 +83,66 @@ describe('Socket', () => {
8483
assert.equal(socket.isOpen, false);
8584
});
8685
});
86+
87+
describe('socketTimeout', () => {
88+
const timeout = 50;
89+
testUtils.testWithClient(
90+
'should timeout with positive socketTimeout values',
91+
async client => {
92+
let timedOut = false;
93+
94+
assert.equal(client.isReady, true, 'client.isReady');
95+
assert.equal(client.isOpen, true, 'client.isOpen');
96+
97+
client.on('error', err => {
98+
assert.equal(
99+
err.message,
100+
`Socket timeout timeout. Expecting data, but didn't receive any in ${timeout}ms.`
101+
);
102+
103+
assert.equal(client.isReady, false, 'client.isReady');
104+
105+
// This is actually a bug with the onSocketError implementation,
106+
// the client should be closed before the error is emitted
107+
process.nextTick(() => {
108+
assert.equal(client.isOpen, false, 'client.isOpen');
109+
});
110+
111+
timedOut = true;
112+
});
113+
await setTimeout(timeout * 2);
114+
if (!timedOut) assert.fail('Should have timed out by now');
115+
},
116+
{
117+
...GLOBAL.SERVERS.OPEN,
118+
clientOptions: {
119+
socket: {
120+
socketTimeout: timeout
121+
}
122+
}
123+
}
124+
);
125+
126+
testUtils.testWithClient(
127+
'should not timeout with undefined socketTimeout',
128+
async client => {
129+
130+
assert.equal(client.isReady, true, 'client.isReady');
131+
assert.equal(client.isOpen, true, 'client.isOpen');
132+
133+
client.on('error', err => {
134+
assert.fail('Should not have timed out or errored in any way');
135+
});
136+
await setTimeout(100);
137+
},
138+
{
139+
...GLOBAL.SERVERS.OPEN,
140+
clientOptions: {
141+
socket: {
142+
socketTimeout: undefined
143+
}
144+
}
145+
}
146+
);
147+
});
87148
});

packages/client/lib/client/socket.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { EventEmitter, once } from 'node:events';
22
import net from 'node:net';
33
import tls from 'node:tls';
4-
import { ConnectionTimeoutError, ClientClosedError, SocketClosedUnexpectedlyError, ReconnectStrategyError } from '../errors';
4+
import { ConnectionTimeoutError, ClientClosedError, SocketClosedUnexpectedlyError, ReconnectStrategyError, SocketTimeoutError } from '../errors';
55
import { setTimeout } from 'node:timers/promises';
66
import { RedisArgument } from '../RESP/types';
77

@@ -23,6 +23,10 @@ type RedisSocketOptionsCommon = {
2323
* 3. `(retries: number, cause: Error) => false | number | Error` -> `number` is the same as configuring a `number` directly, `Error` is the same as `false`, but with a custom error.
2424
*/
2525
reconnectStrategy?: false | number | ReconnectStrategyFunction;
26+
/**
27+
* The timeout (in milliseconds) after which the socket will be closed. `undefined` means no timeout.
28+
*/
29+
socketTimeout?: number;
2630
}
2731

2832
type RedisTcpOptions = RedisSocketOptionsCommon & NetOptions & Omit<
@@ -55,6 +59,7 @@ export default class RedisSocket extends EventEmitter {
5559
readonly #connectTimeout;
5660
readonly #reconnectStrategy;
5761
readonly #socketFactory;
62+
readonly #socketTimeout;
5863

5964
#socket?: net.Socket | tls.TLSSocket;
6065

@@ -85,6 +90,7 @@ export default class RedisSocket extends EventEmitter {
8590
this.#connectTimeout = options?.connectTimeout ?? 5000;
8691
this.#reconnectStrategy = this.#createReconnectStrategy(options);
8792
this.#socketFactory = this.#createSocketFactory(options);
93+
this.#socketTimeout = options?.socketTimeout;
8894
}
8995

9096
#createReconnectStrategy(options?: RedisSocketOptions): ReconnectStrategyFunction {
@@ -103,7 +109,7 @@ export default class RedisSocket extends EventEmitter {
103109
return retryIn;
104110
} catch (err) {
105111
this.emit('error', err);
106-
return this.defaultReconnectStrategy(retries);
112+
return this.defaultReconnectStrategy(retries, err);
107113
}
108114
};
109115
}
@@ -253,6 +259,13 @@ export default class RedisSocket extends EventEmitter {
253259
socket.removeListener('timeout', onTimeout);
254260
}
255261

262+
if (this.#socketTimeout) {
263+
socket.once('timeout', () => {
264+
socket.destroy(new SocketTimeoutError(this.#socketTimeout!));
265+
});
266+
socket.setTimeout(this.#socketTimeout);
267+
}
268+
256269
socket
257270
.once('error', err => this.#onSocketError(err))
258271
.once('close', hadError => {
@@ -341,7 +354,12 @@ export default class RedisSocket extends EventEmitter {
341354
this.#socket?.unref();
342355
}
343356

344-
defaultReconnectStrategy(retries: number) {
357+
defaultReconnectStrategy(retries: number, cause: unknown) {
358+
// By default, do not reconnect on socket timeout.
359+
if (cause instanceof SocketTimeoutError) {
360+
return false;
361+
}
362+
345363
// Generate a random jitter between 0 – 200 ms:
346364
const jitter = Math.floor(Math.random() * 200);
347365
// Delay is an exponential back off, (times^2) * 50 ms, with a maximum value of 2000 ms:

packages/client/lib/errors.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ export class ConnectionTimeoutError extends Error {
1616
}
1717
}
1818

19+
export class SocketTimeoutError extends Error {
20+
constructor(timeout: number) {
21+
super(`Socket timeout timeout. Expecting data, but didn't receive any in ${timeout}ms.`);
22+
}
23+
}
24+
1925
export class ClientClosedError extends Error {
2026
constructor() {
2127
super('The client is closed');

0 commit comments

Comments
 (0)