redis/node-redis · error · Error

FT.CURSOR: the node serving cursor

Error message

FT.CURSOR: the node serving cursor ${token} on index "${argToString(redisArgs[2])}" has left the cluster.

What it means

Thrown when an FT.CURSOR READ or FT.CURSOR DEL token is recognized (it was minted by this client instance), but the node address stored in the cursor binding can no longer be resolved via getMasterByAddress — even after topology data. The node that held the search cursor has left the cluster (crash, failover, or removal), and the server-side cursor state is lost with it.

Solutions

  1. Re-run the original FT.AGGREGATE ...WITHCURSOR query to obtain a fresh cursor on the new serving node
  2. Use larger COUNT values to reduce the number of continuation reads needed (fewer round-trips = less exposure to node loss)
  3. Avoid long-lived cursors during cluster maintenance windows

Example fix

// before — cursor becomes invalid after node failover
const { cursor } = await cluster.ft.aggregate('myIdx', '*', { WITHCURSOR: true });
// ... node fails over ...
await cluster.ft.cursorRead('myIdx', cursor); // throws: node has left

// after — catch and re-run the query
try {
  await cluster.ft.cursorRead('myIdx', cursor);
} catch (err) {
  if (err instanceof Error && err.message.includes('has left the cluster')) {
    // re-run from scratch
    const result = await cluster.ft.aggregate('myIdx', '*', { WITHCURSOR: true, COUNT: 1000 });
    // continue pagination with the new cursor
  }
}
Defensive patterns

Strategy: try-catch

Type guard

function isCursorNodeLost(err: unknown): boolean {
  return err instanceof Error && err.message.includes('has left the cluster');
}

Try / catch

try {
  await cluster.ft.cursorRead('myIdx', cursor);
} catch (err) {
  if (err instanceof Error && err.message.includes('has left the cluster')) {
    // node failover — re-run the original aggregate
    const result = await cluster.ft.aggregate('myIdx', '*', { WITHCURSOR: true, COUNT: 1000 });
    // continue with the new cursor
  } else {
    throw err;
  }
}

Prevention

When it happens

Trigger: FT.AGGREGATE ...WITHCURSOR created a cursor on node X; node X then failed over or was removed from the cluster; a subsequent FT.CURSOR READ tries to route to X but getMasterByAddress returns undefined.

Common situations: RediSearch aggregate queries with cursors during cluster maintenance; node crash mid-pagination; failover event between FT.AGGREGATE and FT.CURSOR READ calls.

Related errors


AI-assisted analysis of redis/node-redis@90fd0652bc (2026-08-11). Data as JSON: /api/errors/f87b02e09d4b4d2d. Report an issue: GitHub.

Appendix: source

Thrown at packages/client/lib/cluster/request-response-policies/ft-cursor.ts:105

 * out or guess.
 */
export const routeFtCursor: RequestRouter = async (slots, parser) => {
  const { redisArgs } = parser;

  // Malformed raw command (missing index/cursor): forward to any node so the
  // server returns its own arity error instead of a client-side TypeError.
  if (redisArgs.length < 4) {
    return [{ client: await slots.nodeClient(slots.getRandomNode()) }];
  }

  const token = argToString(redisArgs[3]);

  const binding = slots.lookupCursor(token);
  if (binding) {
    const client = await slots.getMasterByAddress(binding.address);
    if (client) return [{ client, parser: withCursorArg(parser, binding.cursorId) }];

    throw new Error(
      `FT.CURSOR: the node serving cursor ${token} on index "${argToString(redisArgs[2])}" ` +
      `has left the cluster.`
    );
  }

  throw new Error(
    `FT.CURSOR: unknown cursor ${token} on index "${argToString(redisArgs[2])}". ` +
    `Cluster cursors are minted per client instance and expire when idle — ` +
    `the cursor was not created by this client, has already been exhausted, ` +
    `or has expired.`
  );
};

/** Copy of the FT.CURSOR parser with the cursor argument (index 3) replaced. */
function withCursorArg(parser: CommandParser, cursorId: string): CommandParser {
  const sub = new BasicCommandParser();
  const { redisArgs } = parser;
  for (let i = 0; i < redisArgs.length; i++) {

View on GitHub (pinned to 90fd0652bc)