diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 57af626bf7..83a818454b 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -29,6 +29,7 @@ | isolationPoolOptions | | An object that configures a pool of isolated connections, If you frequently need isolated connections, consider using [createClientPool](https://github.com/redis/node-redis/blob/master/docs/pool.md#creating-a-pool) instead | | pingInterval | | Send `PING` command at interval (in ms). Useful with ["Azure Cache for Redis"](https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-best-practices-connection#idle-timeout) | | disableClientInfo | `false` | Disables `CLIENT SETINFO LIB-NAME node-redis` and `CLIENT SETINFO LIB-VER X.X.X` commands | +| commandTimeout | | Throw an error and abort a command if it takes longer than the specified time (in milliseconds). | ## Reconnect Strategy diff --git a/package-lock.json b/package-lock.json index d9fc9f93f9..7be21d94a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@istanbuljs/nyc-config-typescript": "^1.0.2", "@release-it/bumper": "^7.0.5", "@types/mocha": "^10.0.6", - "@types/node": "^20.11.16", + "@types/node": "^20.19.1", "gh-pages": "^6.1.1", "mocha": "^10.2.0", "nyc": "^15.1.0", @@ -1657,11 +1657,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.11.16", + "version": "20.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.1.tgz", + "integrity": "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.21.0" } }, "node_modules/@types/parse-path": { @@ -6987,7 +6989,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index e192e69d55..9479e1937e 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@istanbuljs/nyc-config-typescript": "^1.0.2", "@release-it/bumper": "^7.0.5", "@types/mocha": "^10.0.6", - "@types/node": "^20.11.16", + "@types/node": "^20.19.1", "gh-pages": "^6.1.1", "mocha": "^10.2.0", "nyc": "^15.1.0", diff --git a/packages/client/lib/client/commands-queue.ts b/packages/client/lib/client/commands-queue.ts index 78c0a01b20..f2f2d791fe 100644 --- a/packages/client/lib/client/commands-queue.ts +++ b/packages/client/lib/client/commands-queue.ts @@ -144,7 +144,7 @@ export default class RedisCommandsQueue { if (this.#maxLength && this.#toWrite.length + this.#waitingForReply.length >= this.#maxLength) { return Promise.reject(new Error('The queue is full')); } else if (options?.abortSignal?.aborted) { - return Promise.reject(new AbortError()); + return Promise.reject(new AbortError(options?.abortSignal?.reason)); } return new Promise((resolve, reject) => { @@ -165,7 +165,7 @@ export default class RedisCommandsQueue { signal, listener: () => { this.#toWrite.remove(node); - value.reject(new AbortError()); + value.reject(new AbortError(signal.reason)); } }; signal.addEventListener('abort', value.abort.listener, { once: true }); diff --git a/packages/client/lib/client/index.spec.ts b/packages/client/lib/client/index.spec.ts index 4f752210db..9da00c721e 100644 --- a/packages/client/lib/client/index.spec.ts +++ b/packages/client/lib/client/index.spec.ts @@ -1,7 +1,7 @@ import { strict as assert } from 'node:assert'; import testUtils, { GLOBAL, waitTillBeenCalled } from '../test-utils'; import RedisClient, { RedisClientOptions, RedisClientType } from '.'; -import { AbortError, ClientClosedError, ClientOfflineError, ConnectionTimeoutError, DisconnectsClientError, ErrorReply, MultiErrorReply, SocketClosedUnexpectedlyError, WatchError } from '../errors'; +import { AbortError, ClientClosedError, ClientOfflineError, ConnectionTimeoutError, DisconnectsClientError, ErrorReply, MultiErrorReply, WatchError } from '../errors'; import { defineScript } from '../lua-script'; import { spy } from 'sinon'; import { once } from 'node:events'; @@ -263,8 +263,47 @@ describe('Client', () => { AbortError ); }, GLOBAL.SERVERS.OPEN); + + testUtils.testWithClient('rejects with AbortError - respects given abortSignal', client => { + + const promise = client.sendCommand(['PING'], { + abortSignal: AbortSignal.abort("my reason") + }) + + assert.rejects( + promise, + AbortError + ); + + promise.catch((error: unknown) => { + assert.ok((error as string).includes("my reason")); + }); + + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + commandTimeout: 50, + } + }); }); + + testUtils.testWithClient('rejects with AbortError on commandTimeout timer', async client => { + const start = process.hrtime.bigint(); + const promise = client.ping(); + + while (process.hrtime.bigint() - start < 10_000_000) { + // block the event loop for 10ms, to make sure the connection will timeout + }; + + assert.rejects(promise, AbortError); + }, { + ...GLOBAL.SERVERS.OPEN, + clientOptions: { + commandTimeout: 10, + } + }); + testUtils.testWithClient('undefined and null should not break the client', async client => { await assert.rejects( client.sendCommand([null as any, undefined as any]), diff --git a/packages/client/lib/client/index.ts b/packages/client/lib/client/index.ts index a446ad8e75..0863d01c92 100644 --- a/packages/client/lib/client/index.ts +++ b/packages/client/lib/client/index.ts @@ -144,6 +144,10 @@ export interface RedisClientOptions< * Tag to append to library name that is sent to the Redis server */ clientInfoTag?: string; + /** + * Provides a timeout in milliseconds. + */ + commandTimeout?: number; } type WithCommands< @@ -526,7 +530,7 @@ export default class RedisClient< async #handshake(chainId: symbol, asap: boolean) { const promises = []; const commandsWithErrorHandlers = await this.#getHandshakeCommands(); - + if (asap) commandsWithErrorHandlers.reverse() for (const { cmd, errorHandler } of commandsWithErrorHandlers) { @@ -632,7 +636,7 @@ export default class RedisClient< // since they could be connected to an older version that doesn't support them. } }); - + commands.push({ cmd: [ 'CLIENT', @@ -889,7 +893,21 @@ export default class RedisClient< return Promise.reject(new ClientOfflineError()); } + if (this._self.#options?.commandTimeout) { + let abortSignal = AbortSignal.timeout(this._self.#options?.commandTimeout); + if (options?.abortSignal) { + abortSignal = AbortSignal.any([ + abortSignal, + options.abortSignal + ]); + } + options = { + ...options, + abortSignal + } + } const promise = this._self.#queue.addCommand(args, options); + this._self.#scheduleWrite(); return promise; } diff --git a/packages/client/lib/errors.ts b/packages/client/lib/errors.ts index db37ec1a9b..eeed0e3bb3 100644 --- a/packages/client/lib/errors.ts +++ b/packages/client/lib/errors.ts @@ -1,6 +1,6 @@ export class AbortError extends Error { - constructor() { - super('The command was aborted'); + constructor(message = '') { + super(`The command was aborted: ${message}`); } }