diff --git a/lib/index.ts b/lib/index.ts index 57a0f62..252450c 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -150,11 +150,16 @@ export class RedisAdapter extends Adapter { this.onrequest(channel, msg); }); - this.subClient.pSubscribe( - this.channel + "*", - this.redisListeners.get("psub"), - true - ); + this.withRetry(() => + this.subClient.pSubscribe( + this.channel + "*", + this.redisListeners.get("psub"), + true + ) + ).catch((err) => { + this.emit("error", err); + }); + this.subClient.subscribe( [ this.requestChannel, @@ -168,7 +173,12 @@ export class RedisAdapter extends Adapter { this.redisListeners.set("pmessageBuffer", this.onmessage.bind(this)); this.redisListeners.set("messageBuffer", this.onrequest.bind(this)); - this.subClient.psubscribe(this.channel + "*"); + this.withRetry(() => this.subClient.psubscribe(this.channel + "*")).catch( + (err) => { + this.emit("error", err); + } + ); + this.subClient.on( "pmessageBuffer", this.redisListeners.get("pmessageBuffer") @@ -941,6 +951,21 @@ export class RedisAdapter extends Adapter { this.pubClient.off("error", this.friendlyErrorHandler); this.subClient.off("error", this.friendlyErrorHandler); } + + private async withRetry(fn: () => Promise) { + let attempt = 1; + while (true) { + try { + return await fn(); + } catch (err) { + if (attempt >= 3) { + throw err; + } + await new Promise((resolve) => setTimeout(resolve, attempt * 100)); + attempt++; + } + } + } } export { createShardedAdapter } from "./sharded-adapter"; diff --git a/package-lock.json b/package-lock.json index c580cf5..6cb9a71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@types/expect.js": "^0.3.29", "@types/mocha": "^8.2.1", "@types/node": "^14.14.7", + "@types/sinon": "^21.0.0", "expect.js": "0.3.1", "ioredis": "^5.3.2", "mocha": "^10.1.0", @@ -25,6 +26,7 @@ "redis": "^4.6.6", "redis-v3": "npm:redis@^3.1.2", "rimraf": "^5.0.5", + "sinon": "^21.0.1", "socket.io": "^4.6.1", "socket.io-client": "^4.1.1", "ts-node": "^10.9.1", @@ -458,6 +460,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.5.7.tgz", "integrity": "sha512-gaOBOuJPjK5fGtxSseaKgSvjiZXQCdLlGg9WYQst+/GRUjmXaiB5kVkeQMRtPc7Q2t93XZcJfBMSwzs/XS9UZw==", "dev": true, + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -503,6 +506,47 @@ "@redis/client": "^1.0.0" } }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.0.tgz", + "integrity": "sha512-cqfapCxwTGsrR80FEgOoPsTonoefMBY7dnUEbQ+GRcved0jvkJLzvX6F4WtN+HBqbPX/SiFsIRUp+IrCW/2I2w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", @@ -564,7 +608,25 @@ "version": "14.14.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.7.tgz", "integrity": "sha512-Zw1vhUSQZYw+7u5dAwNbIA9TuTotpzY/OF7sJM9FqPOF3SPjKnxrjoTktXDZgUjybf4cWVBP7O8wvKdSaGHweg==", - "dev": true + "dev": true, + "peer": true + }, + "node_modules/@types/sinon": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-21.0.0.tgz", + "integrity": "sha512-+oHKZ0lTI+WVLxx1IbJDNmReQaIsQJjN2e7UUrJHEeByG7bFeKJYsv1E75JxTQ9QKJDp21bAa/0W2Xo4srsDnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz", + "integrity": "sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==", + "dev": true, + "license": "MIT" }, "node_modules/accepts": { "version": "1.3.8", @@ -2716,6 +2778,47 @@ "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", "dev": true }, + "node_modules/sinon": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.1.tgz", + "integrity": "sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^15.1.0", + "@sinonjs/samsam": "^8.0.3", + "diff": "^8.0.2", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", + "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/socket.io": { "version": "4.7.5", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz", @@ -2738,6 +2841,7 @@ "version": "2.5.4", "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.4.tgz", "integrity": "sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==", + "peer": true, "dependencies": { "debug": "~4.3.4", "ws": "~8.11.0" @@ -3001,6 +3105,16 @@ "node": ">=0.3.1" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", @@ -3024,6 +3138,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3705,6 +3820,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.5.7.tgz", "integrity": "sha512-gaOBOuJPjK5fGtxSseaKgSvjiZXQCdLlGg9WYQst+/GRUjmXaiB5kVkeQMRtPc7Q2t93XZcJfBMSwzs/XS9UZw==", "dev": true, + "peer": true, "requires": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -3739,6 +3855,42 @@ "dev": true, "requires": {} }, + "@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.0.tgz", + "integrity": "sha512-cqfapCxwTGsrR80FEgOoPsTonoefMBY7dnUEbQ+GRcved0jvkJLzvX6F4WtN+HBqbPX/SiFsIRUp+IrCW/2I2w==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1" + } + }, + "@sinonjs/samsam": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + }, + "dependencies": { + "type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true + } + } + }, "@socket.io/component-emitter": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", @@ -3800,6 +3952,22 @@ "version": "14.14.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.7.tgz", "integrity": "sha512-Zw1vhUSQZYw+7u5dAwNbIA9TuTotpzY/OF7sJM9FqPOF3SPjKnxrjoTktXDZgUjybf4cWVBP7O8wvKdSaGHweg==", + "dev": true, + "peer": true + }, + "@types/sinon": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-21.0.0.tgz", + "integrity": "sha512-+oHKZ0lTI+WVLxx1IbJDNmReQaIsQJjN2e7UUrJHEeByG7bFeKJYsv1E75JxTQ9QKJDp21bAa/0W2Xo4srsDnw==", + "dev": true, + "requires": { + "@types/sinonjs__fake-timers": "*" + } + }, + "@types/sinonjs__fake-timers": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz", + "integrity": "sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==", "dev": true }, "accepts": { @@ -5397,6 +5565,36 @@ "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", "dev": true }, + "sinon": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.1.tgz", + "integrity": "sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^15.1.0", + "@sinonjs/samsam": "^8.0.3", + "diff": "^8.0.2", + "supports-color": "^7.2.0" + }, + "dependencies": { + "diff": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", + "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "socket.io": { "version": "4.7.5", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz", @@ -5416,6 +5614,7 @@ "version": "2.5.4", "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.4.tgz", "integrity": "sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==", + "peer": true, "requires": { "debug": "~4.3.4", "ws": "~8.11.0" @@ -5602,6 +5801,12 @@ } } }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, "type-fest": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", @@ -5621,7 +5826,8 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true + "dev": true, + "peer": true }, "uid2": { "version": "1.0.0", diff --git a/package.json b/package.json index 869e82d..0826b0a 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@types/expect.js": "^0.3.29", "@types/mocha": "^8.2.1", "@types/node": "^14.14.7", + "@types/sinon": "^21.0.0", "expect.js": "0.3.1", "ioredis": "^5.3.2", "mocha": "^10.1.0", @@ -39,6 +40,7 @@ "redis": "^4.6.6", "redis-v3": "npm:redis@^3.1.2", "rimraf": "^5.0.5", + "sinon": "^21.0.1", "socket.io": "^4.6.1", "socket.io-client": "^4.1.1", "ts-node": "^10.9.1", diff --git a/test/adapter-retries.ts b/test/adapter-retries.ts new file mode 100644 index 0000000..cf4a351 --- /dev/null +++ b/test/adapter-retries.ts @@ -0,0 +1,89 @@ +import { createAdapter } from "../lib/index"; +import * as sinon from "sinon"; +import expect from "expect.js"; + +describe("RedisAdapter retries", () => { + it("should retry psubscribe call", async () => { + const pubClient = { + on: sinon.stub(), + off: sinon.stub(), + publish: sinon.stub(), + }; + const subClient = { + on: sinon.stub(), + off: sinon.stub(), + psubscribe: sinon.stub(), + subscribe: sinon.stub(), + }; + + // Mock Redis v3 behavior (psubscribe is used) + // Simulate 2 failures and 1 success + subClient.psubscribe + .onFirstCall() + .rejects(new Error("timeout")) + .onSecondCall() + .rejects(new Error("timeout")) + .onThirdCall() + .resolves(); + + // FIXED: Added server.encoder to mock + const nsp = { + name: "/", + server: { + encoder: {} + }, + on: sinon.stub(), + emit: sinon.stub(), + }; + + // Instantiate adapter + createAdapter(pubClient, subClient)(nsp); + + // Wait for the async retry loop to complete + await new Promise((resolve) => setTimeout(resolve, 1000)); + + expect(subClient.psubscribe.callCount).to.be(3); + }); + + it("should retry pSubscribe call (Redis v4)", async () => { + const pubClient = { + on: sinon.stub(), + off: sinon.stub(), + publish: sinon.stub(), + pSubscribe: sinon.stub(), // This presence triggers Redis v4 mode + }; + const subClient = { + on: sinon.stub(), + off: sinon.stub(), + pSubscribe: sinon.stub(), + subscribe: sinon.stub(), + }; + + // Simulate 2 failures and 1 success + subClient.pSubscribe + .onFirstCall() + .rejects(new Error("timeout")) + .onSecondCall() + .rejects(new Error("timeout")) + .onThirdCall() + .resolves(); + + // FIXED: Added server.encoder to mock + const nsp = { + name: "/", + server: { + encoder: {} + }, + on: sinon.stub(), + emit: sinon.stub(), + }; + + // Instantiate adapter + createAdapter(pubClient, subClient)(nsp); + + // Wait for the async retry loop to complete + await new Promise((resolve) => setTimeout(resolve, 1000)); + + expect(subClient.pSubscribe.callCount).to.be(3); + }); +}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 87d51bf..a5cd567 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,9 +4,12 @@ "allowJs": false, "target": "es2017", "module": "commonjs", - "declaration": true + "declaration": true, + "esModuleInterop": true, + "skipLibCheck": true }, "include": [ - "./lib/**/*" + "./lib/**/*", + "./test/**/*" ] -} +} \ No newline at end of file