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
- Re-run the original FT.AGGREGATE ...WITHCURSOR query to obtain a fresh cursor on the new serving node
- Use larger COUNT values to reduce the number of continuation reads needed (fewer round-trips = less exposure to node loss)
- 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
- Use larger COUNT values to reduce the number of FT.CURSOR READ round-trips (less exposure to node loss)
- Avoid long-lived RediSearch cursors during cluster maintenance or failover windows
- Monitor cluster node health and avoid queries with cursors on unstable nodes
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
- FT.CURSOR: unknown cursor
- Cannot find node
- SCAN: node serving this cursor has left the cluster —…
- SCAN: unknown cursor
- All replies must be array of numbers for logical AND…
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)