redis/node-redis · error · Error
FT.CURSOR: unknown cursor ${token} on index "${argToString(r
Error message
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. What it means
FT.CURSOR's sticky router could not find the caller's token in the cursor-binding map at all (`lookupCursor` returned undefined). Cluster cursor tokens are client-minted virtual ids bound per RedisCluster instance and evicted on exhaustion, explicit DEL, or idle-expiry past MAXIDLE (default TTL). A MISS means the token is unusable by this client — the router throws before any network call rather than fan out or route to a random node.
Source
Thrown at packages/client/lib/cluster/request-response-policies/ft-cursor.ts:111
// 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++) {
sub.push(i === 3 ? cursorId : redisArgs[i] as RedisArgument);
}
return sub;
}
/**View on GitHub (pinned to bb5beb5657)
Solutions
- Iterate FT.CURSOR READ on the same cluster client instance that issued the FT.AGGREGATE.
- Don't persist or share cursor tokens across processes/clients — they are per-instance handles.
- Drive the cursor loop promptly (within MAXIDLE) and stop when the returned cursor is 0/exhausted.
- If you genuinely need resumable scans across restarts, re-issue FT.AGGREGATE rather than reusing a stale token.
Example fix
// before — token reused after exhaustion, or from another client
const { cursor } = await cluster.ft.aggregate('idx', '*', { WITHCURSOR: true });
await readAll(cluster, cursor); // loop ends, binding evicted
await cluster.sendCommand(['FT.CURSOR', 'READ', 'idx', cursor]); // throws: unknown cursor
// after — stop at exhaustion, never reuse a consumed token
let cursor = (await cluster.ft.aggregate('idx', '*', { WITHCURSOR: true })).cursor;
do {
const batch = await cluster.sendCommand(['FT.CURSOR', 'READ', 'idx', cursor, 'COUNT', '100']);
cursor = extractCursor(batch);
} while (cursor !== 0 && cursor !== '0'); Defensive patterns
Strategy: validation
Validate before calling
// Track cursor lifecycle in the caller so you never reuse a stale token.
async function iterateAggregate(cluster, index, query) {
const first = await cluster.ft.aggregate(index, query, { WITHCURSOR: true });
let cursor = first.cursor;
const all = [...first.results];
while (cursor !== 0 && cursor !== '0' && cursor !== undefined) {
const batch = await cluster.sendCommand(['FT.CURSOR', 'READ', index, String(cursor), 'COUNT', '100']);
// extractCursorValue mirrors the library's own extraction
const next = Array.isArray(batch) ? batch[1] : (batch?.cursor ?? 0);
all.push(...(Array.isArray(batch) ? batch[0] : batch.results ?? []));
cursor = next;
}
return all;
} Try / catch
try {
await cluster.sendCommand(['FT.CURSOR', 'READ', 'idx', token]);
} catch (e) {
if (/unknown cursor/.test(e.message)) {
// token was never valid here, exhausted, or expired — restart the aggregation
} else throw e;
} Prevention
- Always iterate FT.CURSOR on the same cluster client instance that issued the FT.AGGREGATE.
- Never persist, serialize, or share cursor tokens across processes/clients.
- Stop iterating once the returned cursor is 0 — the binding is then evicted.
- Complete the cursor loop within MAXIDLE (default 300s) to avoid idle-expiry.
When it happens
Trigger: Calling `FT.CURSOR READ`/`DEL` with a token that: (a) was minted by a different cluster client instance; (b) was already read to exhaustion (server returned cursor 0, which evicts the binding); (c) was explicitly DEL'd; (d) sat idle longer than MAXIDLE/the default TTL and was swept by `#sweepStaleCursors`; or (e) is simply a wrong/garbage value.
Common situations: Serializing cursors across processes or using a cursor from a pooled/different connection; pausing iteration longer than MAXIDLE (default 300s); reusing a cursor after the loop already saw cursor 0; copy-paste typos in the token.
Related errors
- FT.CURSOR: the node serving cursor ${token} on index "${argT
- topologyRefreshOnReconnectionAttempt must be undefined, fals
- topologyRefreshOnReconnectionAttempt should return `false |
- Client Side Caching is only supported with RESP3
- Cluster already open
AI-assisted analysis of redis/node-redis@bb5beb5657 (2026-08-03).
Data as JSON: /data/errors/d50b85084fe70d28.json.
Report an issue: GitHub.