ioredis
Version:
A robust, performance-focused and full-featured Redis client for Node.js.
451 lines (450 loc) • 18.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.readyHandler = exports.errorHandler = exports.closeHandler = exports.connectHandler = void 0;
const redis_errors_1 = require("redis-errors");
const Command_1 = require("../Command");
const errors_1 = require("../errors");
const utils_1 = require("../utils");
const version_1 = require("../utils/version");
const DataHandler_1 = require("../DataHandler");
const HimportCoordinator_1 = require("../himport/HimportCoordinator");
const debug = (0, utils_1.Debug)("connection");
function getHandshakeCommands(self) {
const commands = [];
if (self.condition.protocol === 3) {
const helloCommandArgs = [self.condition.protocol];
if (self.condition.auth) {
helloCommandArgs.push("AUTH");
if (Array.isArray(self.condition.auth)) {
helloCommandArgs.push(self.condition.auth[0], self.condition.auth[1]);
}
else {
helloCommandArgs.push("default", self.condition.auth);
}
}
commands.push({
kind: "hello",
send: () => self.hello(helloCommandArgs),
errorHandler: handleAuthError,
});
}
else if (self.condition.auth) {
commands.push({
kind: "auth",
send: () => self.auth(self.condition.auth),
errorHandler: handleAuthError,
});
}
if (self.condition.select) {
commands.push({
kind: "select",
send: () => self.select(self.condition.select),
errorHandler: (err) => self.silentEmit("error", err),
});
}
if (self.options.connectionName) {
debug("set the connection name [%s]", self.options.connectionName);
commands.push({
kind: "client",
send: () => self.client("setname", self.options.connectionName),
errorHandler: utils_1.noop,
});
}
if (self.options.readOnly) {
debug("set the connection to readonly mode");
commands.push({
kind: "readonly",
send: () => self.readonly(),
errorHandler: utils_1.noop,
});
}
if (!self.options.disableClientInfo) {
debug("set the client info");
commands.push({
kind: "client",
send: () => self.client("SETINFO", "LIB-VER", version_1.PACKAGE_VERSION),
errorHandler: utils_1.noop,
});
commands.push({
kind: "client",
send: () => self.client("SETINFO", "LIB-NAME", self.options?.clientInfoTag
? `ioredis(${self.options.clientInfoTag})`
: "ioredis"),
errorHandler: utils_1.noop,
});
}
const himportBinding = (0, HimportCoordinator_1.getHimportBinding)(self);
if (himportBinding && himportBinding.role !== "replica") {
for (const definition of himportBinding.coordinator.getDefinitions()) {
commands.push({
kind: "himport",
send: () => himportBinding.coordinator.ensurePrepared(self, definition) ??
Promise.resolve(),
errorHandler: (err) => self.silentEmit("error", err),
});
}
}
return commands;
}
async function sendHandshake(commands, protocol) {
if (protocol !== 3) {
await Promise.all(commands.map(({ send, errorHandler }) => errorHandler ? send().catch(errorHandler) : send()));
return;
}
// HELLO may carry AUTH. Send everything optimistically, then inspect
// HELLO before surfacing setup errors.
const results = await Promise.allSettled(commands.map(({ send }) => send()));
const helloIndex = commands.findIndex(({ kind }) => kind === "hello");
const helloResult = helloIndex === -1 ? undefined : results[helloIndex];
if (helloResult?.status === "rejected" &&
isProtocolNegotiationError(helloResult.reason)) {
throw helloResult.reason;
}
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === "rejected") {
const { errorHandler } = commands[i];
if (errorHandler) {
errorHandler(result.reason);
continue;
}
throw result.reason;
}
}
}
function connectHandler(self) {
return async function () {
try {
self.resetCommandQueue();
self.condition.handshake = true;
self.setStatus("connect");
/*
No need to keep the reference of DataHandler here
because we don't need to do the cleanup.
`Stream#end()` will remove all listeners for us.
*/
new DataHandler_1.default(self, {
stringNumbers: self.options.stringNumbers,
replyMapping: self.condition.replyMapping,
});
const { connectionEpoch } = self;
const himportBinding = (0, HimportCoordinator_1.getHimportBinding)(self);
himportBinding?.coordinator.beginSession(self);
const isActiveConnect = () => connectionEpoch === self.connectionEpoch && self.status === "connect";
const finishHandshake = () => {
if (!isActiveConnect()) {
return false;
}
self.condition.handshake = false;
return true;
};
try {
await sendHandshake(getHandshakeCommands(self), self.condition.protocol);
}
catch (err) {
// The connection may have been closed (and possibly already
// reconnected) while the client setup commands above were still
// in flight. In that case this callback is stale and the
// connection is no longer the one we are setting up, so calling
// readyHandler() would mark a dead connection as "ready" and
// permanently stop reconnection. The enableReadyCheck branch
// below is already guarded by the same connectionEpoch check.
// See https://github.com/redis/ioredis/issues/2099
if (!isActiveConnect()) {
return;
}
// Only an unsupported-RESP3 error is recoverable; everything else is fatal.
if (!isProtocolNegotiationError(err)) {
return self.recoverFromFatalError(err, err);
}
// resp3 shapes are unavailable on RESP2, but the data is unaffected
// (RESP2 never sends the remapped frames), so just warn — don't touch
// the option.
if (self.options.replyMapping === "resp3") {
console.warn('[WARN] replyMapping "resp3" was requested, but the server does not support RESP3. ' +
"Replies will use RESP2-compatible shapes until connected to a server that supports RESP3.");
}
debug("server rejected RESP3, downgrading connection to RESP2");
self.condition.protocol = 2;
self.condition.replyMapping = "legacy";
try {
await sendHandshake(getHandshakeCommands(self), self.condition.protocol);
}
catch (downgradeErr) {
if (!isActiveConnect()) {
return;
}
return self.recoverFromFatalError(downgradeErr, downgradeErr);
}
}
if (!isActiveConnect()) {
return;
}
// Keep the public "connect" state/event while the internal handshake gate
// stays closed. INFO is a handshake command so it's still writable here,
// while a user SUBSCRIBE is not — it stays queued and can't race the
// connection into subscriber mode before we're ready (which would get
// INFO rejected).
if (!self.options.enableReadyCheck) {
if (!finishHandshake()) {
return;
}
return exports.readyHandler(self)();
}
self._readyCheck(function (err, info) {
if (!isActiveConnect()) {
return;
}
if (err) {
self.recoverFromFatalError(err, err);
}
else if (self.connector.check(info)) {
if (!finishHandshake()) {
return;
}
exports.readyHandler(self)();
}
else {
self.disconnect(true);
}
});
}
catch (err) {
self.recoverFromFatalError(err, err);
}
};
}
exports.connectHandler = connectHandler;
function handleAuthError(err) {
const msg = err.message || "";
if (msg.indexOf("no password is set") !== -1) {
console.warn("[WARN] Redis server does not require a password, but a password was supplied.");
return;
}
if (msg.indexOf("without any password configured for the default user") !== -1) {
console.warn("[WARN] This Redis server's `default` user does not require a password, but a password was supplied");
return;
}
if (msg.indexOf("wrong number of arguments for 'auth' command") !== -1) {
console.warn(`[ERROR] The server returned "wrong number of arguments for 'auth' command". You are probably passing both username and password to Redis version 5 or below. You should only pass the 'password' option for Redis version 5 and under.`);
return;
}
throw err;
}
// True when HELLO 3 failed because the server can't do RESP3: no HELLO at all
// (Redis < 6) or HELLO present but the version refused (NOPROTO).
function isProtocolNegotiationError(err) {
const msg = (err.message || "").toUpperCase();
return (msg.includes("NOPROTO") ||
(msg.includes("UNKNOWN COMMAND") && msg.includes("HELLO")));
}
function abortError(command) {
const err = new redis_errors_1.AbortError("Command aborted due to connection close");
err.command = {
name: command.name,
args: command.args,
};
return err;
}
// If a contiguous set of pipeline commands starts from index zero then they
// can be safely reattempted. If however we have a chain of pipelined commands
// starting at index 1 or more it means we received a partial response before
// the connection close and those pipelined commands must be aborted. For
// example, if the queue looks like this: [2, 3, 4, 0, 1, 2] then after
// aborting and purging we'll have a queue that looks like this: [0, 1, 2]
function abortIncompletePipelines(commandQueue) {
let expectedIndex = 0;
for (let i = 0; i < commandQueue.length;) {
const command = commandQueue.peekAt(i)?.command;
const pipelineIndex = command.pipelineIndex;
if (pipelineIndex === undefined || pipelineIndex === 0) {
expectedIndex = 0;
}
if (pipelineIndex !== undefined && pipelineIndex !== expectedIndex++) {
commandQueue.remove(i, 1);
command.reject(abortError(command));
continue;
}
i++;
}
}
// If only a partial transaction result was received before connection close,
// we have to abort any transaction fragments that may have ended up in the
// offline queue
function abortTransactionFragments(commandQueue) {
for (let i = 0; i < commandQueue.length;) {
const command = commandQueue.peekAt(i)?.command;
if (command.name === "multi") {
break;
}
if (command.name === "exec") {
commandQueue.remove(i, 1);
command.reject(abortError(command));
break;
}
if (command.inTransaction) {
commandQueue.remove(i, 1);
command.reject(abortError(command));
}
else {
i++;
}
}
}
function closeHandler(self) {
return function () {
const prevStatus = self.status;
self.setStatus("close");
// The timeout belongs to the stream that just closed and must not survive reconnect.
if (self.socketTimeoutTimer !== undefined) {
clearTimeout(self.socketTimeoutTimer);
self.socketTimeoutTimer = undefined;
}
if (self.commandQueue.length) {
abortIncompletePipelines(self.commandQueue);
}
if (self.offlineQueue.length) {
abortTransactionFragments(self.offlineQueue);
}
if (prevStatus === "ready") {
if (!self.prevCondition) {
self.prevCondition = self.condition;
}
if (self.commandQueue.length) {
self.prevCommandQueue = self.commandQueue;
}
}
if (self.manuallyClosing) {
self.manuallyClosing = false;
debug("skip reconnecting since the connection is manually closed.");
return close();
}
if (typeof self.options.retryStrategy !== "function") {
debug("skip reconnecting because `retryStrategy` is not a function");
return close();
}
const retryDelay = self.options.retryStrategy(++self.retryAttempts);
if (typeof retryDelay !== "number") {
debug("skip reconnecting because `retryStrategy` doesn't return a number");
return close();
}
debug("reconnect in %sms", retryDelay);
self.setStatus("reconnecting", retryDelay);
self.reconnectTimeout = setTimeout(function () {
self.reconnectTimeout = null;
self.connect().catch(utils_1.noop);
}, retryDelay);
const { maxRetriesPerRequest } = self.options;
if (typeof maxRetriesPerRequest === "number") {
if (maxRetriesPerRequest < 0) {
debug("maxRetriesPerRequest is negative, ignoring...");
}
else {
const remainder = self.retryAttempts % (maxRetriesPerRequest + 1);
if (remainder === 0) {
debug("reach maxRetriesPerRequest limitation, flushing command queue...");
self.flushQueue(new errors_1.MaxRetriesPerRequestError(maxRetriesPerRequest));
}
}
}
};
function close() {
self.setStatus("end");
self.flushQueue(new Error(utils_1.CONNECTION_CLOSED_ERROR_MSG));
}
}
exports.closeHandler = closeHandler;
function errorHandler(self) {
return function (error) {
debug("error: %s", error);
self.silentEmit("error", error);
};
}
exports.errorHandler = errorHandler;
function readyHandler(self) {
return function () {
self.setStatus("ready");
self.retryAttempts = 0;
if (self.options.monitor) {
self.call("monitor").then(() => self.setStatus("monitoring"), (error) => self.emit("error", error));
const { sendCommand } = self;
self.sendCommand = function (command) {
if (Command_1.default.checkFlag("VALID_IN_MONITOR_MODE", command.name)) {
return sendCommand.call(self, command);
}
command.reject(new Error("Connection is in monitoring mode, can't process commands."));
return command.promise;
};
self.once("close", function () {
delete self.sendCommand;
});
return;
}
const finalSelect = self.prevCondition
? self.prevCondition.select
: self.condition.select;
if (self.prevCondition) {
const condition = self.prevCondition;
self.prevCondition = null;
if (condition.subscriber && self.options.autoResubscribe) {
// We re-select the previous db first since
// `SELECT` command is not valid in sub mode.
if (self.condition.select !== finalSelect) {
debug("connect to db [%d]", finalSelect);
self.select(finalSelect);
}
const subscribeChannels = condition.subscriber.channels("subscribe");
if (subscribeChannels.length) {
debug("subscribe %d channels", subscribeChannels.length);
self.subscribe(subscribeChannels);
}
const psubscribeChannels = condition.subscriber.channels("psubscribe");
if (psubscribeChannels.length) {
debug("psubscribe %d channels", psubscribeChannels.length);
self.psubscribe(psubscribeChannels);
}
const ssubscribeChannels = condition.subscriber.channels("ssubscribe");
if (ssubscribeChannels.length) {
debug("ssubscribe %s", ssubscribeChannels.length);
for (const channel of ssubscribeChannels) {
self.ssubscribe(channel);
}
}
}
}
if (self.prevCommandQueue) {
if (self.options.autoResendUnfulfilledCommands) {
debug("resend %d unfulfilled commands", self.prevCommandQueue.length);
while (self.prevCommandQueue.length > 0) {
const item = self.prevCommandQueue.shift();
if (item.select !== self.condition.select &&
item.command.name !== "select") {
self.select(item.select);
}
self.sendCommand(item.command, item.stream);
}
}
else {
self.prevCommandQueue = null;
}
}
if (self.offlineQueue.length) {
debug("send %d commands in offline queue", self.offlineQueue.length);
const offlineQueue = self.offlineQueue;
self.resetOfflineQueue();
while (offlineQueue.length > 0) {
const item = offlineQueue.shift();
if (item.select !== self.condition.select &&
item.command.name !== "select") {
self.select(item.select);
}
self.sendCommand(item.command, item.stream);
}
}
if (self.condition.select !== finalSelect) {
debug("connect to db [%d]", finalSelect);
self.select(finalSelect);
}
};
}
exports.readyHandler = readyHandler;