Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,7 @@ The Node Redis client class is an Nodejs EventEmitter and it emits an event each
| `end` | Connection has been closed (via `.close()` or `.destroy()`) | _No arguments_ |
| `error` | An error has occurred—usually a network issue such as "Socket closed unexpectedly" | `(error: Error)` |
| `reconnecting` | Client is trying to reconnect to the server | _No arguments_ |
| `terminated` | Reconnection has stopped because `reconnectStrategy` returned `false` or an `Error` | `(cause: Error)` |
| `sharded-channel-moved` | See [here](https://github.com/redis/node-redis/blob/master/docs/pub-sub.md#sharded-channel-moved-event) | See [here](https://github.com/redis/node-redis/blob/master/docs/pub-sub.md#sharded-channel-moved-event) |
| `invalidate` | Client Tracking is on with `emitInvalidate` and a key is invalidated | `(key: RedisItem \| null)` |

Expand Down
9 changes: 9 additions & 0 deletions docs/client-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ createClient({
});
```

An `'error'` event fires on every disconnect, including ones the client is about to retry, so it can't tell you whether reconnection is still in progress. Once `reconnectStrategy` gives up (returns `false` or an `Error`), the client emits a `'terminated'` event before the companion `'error'` event. This is the signal that reconnection has permanently stopped. Call `destroy()` before replacing the client so its resources are released:

```javascript
client.on('terminated', cause => {
console.error('client will not reconnect:', cause);
queueMicrotask(() => client.destroy());
});
```

## TLS

To enable TLS, set `socket.tls` to `true`. Below are some basic examples.
Expand Down
20 changes: 20 additions & 0 deletions packages/client/lib/client/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1551,6 +1551,26 @@ describe('Client', () => {

describe("socket errors during handshake", () => {

it("should re-emit terminated from the socket", async () => {
const client = createClient({
socket: {
host: "error",
connectTimeout: 1,
reconnectStrategy: false
}
});
client.on("error", () => {});

const terminated = new Promise<Error>(resolve => {
client.once("terminated", resolve);
});
await assert.rejects(client.connect());

const cause = await terminated;
assert.ok(cause instanceof Error);
client.destroy();
});

it("should successfully connect when server accepts connection immediately", async () => {
const { log, client, teardown } = await setup({}, 0);
await client.connect();
Expand Down
1 change: 1 addition & 0 deletions packages/client/lib/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,7 @@ export default class RedisClient<
this.#maybeScheduleWrite();
})
.on('reconnecting', () => this.emit('reconnecting'))
.on('terminated', cause => this.emit('terminated', cause))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clean up client resources on terminal transition

When a previously connected client has an active streaming-credentials subscription or was registered with the initialized OpenTelemetry ClientRegistry, this callback only forwards the terminal event and leaves those client-level resources alive. Their cleanup occurs only in RedisClient.destroy() (index.ts:2236-2238), so an application that follows the new documentation by dropping this client and creating a replacement without explicitly destroying the old one leaves a registry handle that strongly retains the client and may leave its credential observer subscribed. Dispose these resources on the terminal path or explicitly require destroy() before replacement.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept cleanup explicit rather than doing it in the event forwarding callback. terminated reports that reconnection has ended, while destroy() remains the client lifecycle operation that releases credentials and metrics resources. The documentation now calls destroy() before replacing the client, and the example uses it.

.on('drain', () => this.#maybeScheduleWrite())
.on('end', () => this.emit('end'));
}
Expand Down
177 changes: 177 additions & 0 deletions packages/client/lib/client/socket.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import net from 'node:net';
import RedisSocket, { RedisSocketOptions } from './socket';
import testUtils, { GLOBAL } from '../test-utils';
import { setTimeout } from 'timers/promises';
import { ReconnectStrategyError } from '../errors';

describe('Socket', () => {
const CLIENT_ID = 'test-client-id';
Expand Down Expand Up @@ -87,6 +88,182 @@ describe('Socket', () => {
});
});

describe('terminated event (#2948)', () => {
it('should emit `terminated` when reconnectStrategy gives up on the initial connection', async () => {
const socket = createSocket({
host: 'error',
connectTimeout: 1,
reconnectStrategy: false
});

const events: string[] = [];
let terminatedCause: Error | undefined;
socket.on('terminated', cause => {
events.push('terminated');
terminatedCause = cause;
});
socket.on('error', () => events.push('error'));

await assert.rejects(socket.connect());

assert.ok(terminatedCause instanceof Error);
assert.deepEqual(events.slice(-2), ['terminated', 'error']);
assert.equal(socket.isOpen, false);
});

it('should emit `terminated` with the wrapped error when a custom reconnectStrategy gives up', async () => {
const reconnectStrategy = spy((retries: number) => {
if (retries === 1) return new Error('done');
return 0;
});

const socket = createSocket({
host: 'error',
connectTimeout: 1,
reconnectStrategy
});

const events: string[] = [];
let terminatedCause: Error | undefined;
socket.on('terminated', cause => {
events.push('terminated');
terminatedCause = cause;
});
socket.on('error', () => events.push('error'));

await assert.rejects(socket.connect());

assert.ok(terminatedCause instanceof ReconnectStrategyError, 'terminated cause should be a ReconnectStrategyError');
assert.deepEqual(events.slice(-2), ['terminated', 'error']);
});

it('should emit `terminated` — not just `error` — when the connection is lost after being ready', async () => {
// This is the scenario from #2948: `error` fires on *every* disconnect,
// including ones the client is about to retry, so a listener can't tell
// "still retrying" apart from "reconnectStrategy gave up, this client
// is dead". Before this fix, losing an already-ready connection only
// ever emitted `error`, indistinguishable from a transient one.
const connections: net.Socket[] = [];
const server = net.createServer(conn => {
conn.on('error', () => { /* ignore */ });
connections.push(conn);
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as net.AddressInfo;
const firstConnection = once(server, 'connection') as Promise<[net.Socket]>;

try {
const socket = createSocket({
host: '127.0.0.1',
port,
// Give up as soon as the live connection dies, instead of retrying.
reconnectStrategy: false
});

await socket.connect();
assert.equal(socket.isReady, true, 'socket.isReady');

const events: string[] = [];
let terminatedCause: Error | undefined;
socket.on('terminated', cause => {
events.push('terminated');
terminatedCause = cause;
});
socket.on('error', () => events.push('error'));

const [conn] = await firstConnection;
conn.destroy();
const [errCause] = await once(socket, 'error') as [Error];

assert.deepEqual(events, ['terminated', 'error']);
assert.equal(terminatedCause, errCause, 'terminated should carry the same cause as error');
assert.equal(socket.isOpen, false, 'socket.isOpen');
} finally {
for (const conn of connections) conn.destroy();
await new Promise<void>(resolve => server.close(() => resolve()));
}
});

it('should not emit `terminated` when the reconnectStrategy schedules a retry', async () => {
const socket = createSocket({
host: 'error',
connectTimeout: 1,
reconnectStrategy: 0
});

let terminatedCount = 0;
socket.on('terminated', () => terminatedCount++);

socket.connect();
await once(socket, 'error');
assert.equal(socket.isOpen, true);
assert.equal(terminatedCount, 0, 'terminated must not fire while still retrying');

socket.destroy();
});

it('should not reconnect when an error listener destroys the socket', async () => {
const connections: net.Socket[] = [];
const server = net.createServer(conn => {
conn.on('error', () => { /* ignore */ });
connections.push(conn);
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as net.AddressInfo;
const firstConnection = once(server, 'connection') as Promise<[net.Socket]>;

try {
const socket = createSocket({
host: '127.0.0.1',
port,
reconnectStrategy: 0
});
socket.on('error', () => socket.destroy());

await socket.connect();
const [conn] = await firstConnection;
conn.destroy();
await once(socket, 'end');
await setTimeout(10);

assert.equal(connections.length, 1, 'destroyed socket must not reconnect');
assert.equal(socket.isOpen, false, 'socket.isOpen');
} finally {
for (const conn of connections) conn.destroy();
await new Promise<void>(resolve => server.close(() => resolve()));
}
});

it('should emit `terminated` before `error` when the handshake fails', async () => {
const connections: net.Socket[] = [];
const server = net.createServer(conn => {
conn.on('error', () => { /* ignore */ });
connections.push(conn);
setImmediate(() => conn.destroy());
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as net.AddressInfo;

try {
const socket = new RedisSocket(() => new Promise(() => { /* wait for socket failure */ }), CLIENT_ID, {
host: '127.0.0.1',
port,
reconnectStrategy: false
});
const events: string[] = [];
socket.on('terminated', () => events.push('terminated'));
socket.on('error', () => events.push('error'));

await assert.rejects(socket.connect());

assert.deepEqual(events, ['terminated', 'error']);
} finally {
for (const conn of connections) conn.destroy();
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
});

describe('initiator interruption (#3346)', () => {
it('should keep retrying when the socket dies while the initiator is suspended', async () => {
const connections: net.Socket[] = [];
Expand Down
56 changes: 46 additions & 10 deletions packages/client/lib/client/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,13 @@ const retryIn = strategy(retries, cause);
};
}

/**
* The single choke point where `reconnectStrategy` giving up (`false` or an
* `Error`) is handled: closes the socket for good and emits `'terminated'`
* so a caller reacting only to `'error'` — which also fires on every
* *retried* disconnect — can tell "still retrying" apart from "reconnection
* has permanently stopped, the client is unusable from here on".
*/
#shouldReconnect(retries: number, cause: Error) {
const retryIn = this.#reconnectStrategy(retries, cause);
if (retryIn === false) {
Expand All @@ -215,6 +222,7 @@ const retryIn = strategy(retries, cause);
internal: false,
clientId: this.#clientId
}));
this.emit('terminated', cause);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Emit terminated before the first post-ready error

When an already-ready connection dies, #onSocketError still emits error before invoking #shouldReconnect, so events.once(client, 'terminated') rejects before this event is reached whenever the strategy gives up. Fresh evidence in the final diff is the unchanged this.emit('error', err) at line 416 preceding the call to this changed method at line 425; determine the terminal decision before that first error emission so the documented ordering also holds for post-ready disconnects.

Useful? React with 👍 / 👎.

this.emit('error', cause);
return cause;
} else if (retryIn instanceof Error) {
Expand All @@ -225,8 +233,10 @@ const retryIn = strategy(retries, cause);
internal: false,
clientId: this.#clientId
}));
const terminatedBy = new ReconnectStrategyError(retryIn, cause);
this.emit('terminated', terminatedBy);
this.emit('error', cause);
return new ReconnectStrategyError(retryIn, cause);
return terminatedBy;
}

return retryIn;
Expand Down Expand Up @@ -397,22 +407,48 @@ const retryIn = strategy(retries, cause);
this.#isReady = false;
const socket = this.#socket;
this.#socket = undefined;
publish(CHANNELS.ERROR, () => ({
error: err,
origin: 'client',
internal: false,
clientId: this.#clientId
}));
this.emit('error', err);

socket?.removeAllListeners('data');
socket?.destroy();

if (wasReady) {
publish(CHANNELS.CONNECTION_CLOSED, () => ({ clientId: this.#clientId, reason: 'error', wasConnected: true }));
}

if (!wasReady || !this.#isOpen || typeof this.#shouldReconnect(0, err) !== 'number') return;
if (!wasReady) {
if (!this.#isOpen) {
publish(CHANNELS.ERROR, () => ({
error: err,
origin: 'client',
internal: false,
clientId: this.#clientId
}));
this.emit('error', err);
}
return;
}
Comment thread
cursor[bot] marked this conversation as resolved.

if (!this.#isOpen) {
publish(CHANNELS.ERROR, () => ({
error: err,
origin: 'client',
internal: false,
clientId: this.#clientId
}));
this.emit('error', err);
return;
Comment thread
cursor[bot] marked this conversation as resolved.
}

const retryIn = this.#shouldReconnect(0, err);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the socket cause when the reconnect strategy throws

When an established connection fails and the custom reconnectStrategy throws or returns an invalid value, this call enters #createReconnectStrategy's catch block, which synchronously emits the strategy exception before the actual socket error is emitted below. The RedisClient error handler therefore flushes pending commands with the strategy exception, whereas previously the waiting commands were rejected with the real connection failure before the strategy ran. Determine the retry decision without letting that intermediate error consume the command queue, or preserve the original socket-error emission ordering for this fallback path.

Useful? React with 👍 / 👎.

if (typeof retryIn !== 'number') return;

publish(CHANNELS.ERROR, () => ({
error: err,
origin: 'client',
internal: false,
clientId: this.#clientId
}));
this.emit('error', err);
if (!this.#isOpen) return;

this.emit('reconnecting');
this.#connect().catch(() => {
Comment on lines 436 to 437

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recheck isOpen after emitting the reconnectable error

When a ready connection fails with a numeric retry strategy and an error listener synchronously calls client.destroy() or client.close(), that listener sets the socket's #isOpen to false, but execution then reaches these lines unconditionally. Because #connect() performs its first attempt before checking the loop condition, the destroyed client can establish a new socket and emit connect/ready after end, leaving a live connection behind; recheck #isOpen after the synchronous error emission before announcing or starting reconnection.

Useful? React with 👍 / 👎.

Expand Down
1 change: 1 addition & 0 deletions packages/redis/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ The Node Redis client class is an Nodejs EventEmitter and it emits an event each
| `end` | Connection has been closed (via `.disconnect()`) | _No arguments_ |
| `error` | An error has occurred—usually a network issue such as "Socket closed unexpectedly" | `(error: Error)` |
| `reconnecting` | Client is trying to reconnect to the server | _No arguments_ |
| `terminated` | Reconnection has stopped because `reconnectStrategy` returned `false` or an `Error` | `(cause: Error)` |
| `sharded-channel-moved` | See [here](https://github.com/redis/node-redis/blob/master/docs/pub-sub.md#sharded-channel-moved-event) | See [here](https://github.com/redis/node-redis/blob/master/docs/pub-sub.md#sharded-channel-moved-event) |

> :warning: You **MUST** listen to `error` events. If a client doesn't have at least one `error` listener registered and
Expand Down