redis/redis-py · error · RedisClusterException

Cannot execute FT.CURSOR commands without FT.AGGREGATE

Error message

Cannot execute FT.CURSOR commands without FT.AGGREGATE

What it means

Raised by get_special_nodes() in the async cluster client when an FT.CURSOR command is dispatched but self._aggregate_nodes is empty. The cluster client only populates _aggregate_nodes as a side effect of running FT.AGGREGATE (cluster.py:979), so it knows which shard produced the cursor. Without that prior call there is no node to send the cursor read to, so the library refuses rather than guessing.

Solutions

  1. Run FT.AGGREGATE on the same RedisCluster client first, then immediately issue FT.CURSOR on the returned cursor id.
  2. Keep the aggregate and cursor calls on the same client instance; do not reinitialize the cluster between them.
  3. If the cluster reinitialized (MOVED storm / resharding), re-issue the FT.AGGREGATE to obtain a fresh cursor and node set.
  4. Avoid persisting a cursor id across process/client restarts; cursors are tied to a specific shard and the client's cached aggregate nodes.

Example fix

// before
rc = RedisCluster(host='localhost', port=7000)
await rc.execute_command('FT.CURSOR', 'idx', '0', 'COUNT', 10)

// after
rc = RedisCluster(host='localhost', port=7000)
await rc.execute_command('FT.AGGREGATE', 'idx', '*', 'WITHCURSOR', 'COUNT', 10)
await rc.execute_command('FT.CURSOR', 'idx', '<cursor_id>', 'COUNT', 10)
Defensive patterns

Strategy: validation

Validate before calling

# Before issuing FT.CURSOR, confirm the client has aggregate nodes cached
def assert_aggregate_ready(rc):
    if not getattr(rc, '_aggregate_nodes', None):
        raise RuntimeError('Run FT.AGGREGATE on this client before FT.CURSOR')

await assert_aggregate_ready(rc)
await rc.execute_command('FT.CURSOR', 'idx', cursor_id, 'COUNT', 10)

Type guard

def has_aggregate_nodes(rc) -> bool:
    return bool(getattr(rc, '_aggregate_nodes', None))

Try / catch

from redis.exceptions import RedisClusterException
try:
    await rc.execute_command('FT.CURSOR', 'idx', cursor_id)
except RedisClusterException as e:
    if 'FT.CURSOR commands without FT.AGGREGATE' in str(e):
        await rc.execute_command('FT.AGGREGATE', 'idx', '*', 'WITHCURSOR')
        # then retry the cursor read

Prevention

When it happens

Trigger: Calling FT.CURSOR (e.g. client.ft().cursor_read(...) / cursor_delete) on a RedisCluster client before any FT.AGGREGATE has been executed against it in the current slots-cache lifetime, or on a freshly initialized client where the cached aggregate nodes were cleared by a reinitialize() call.

Common situations: Using redis-py's search module with a RedisCluster handle and resuming an aggregate cursor across two separate client instances or after a cluster topology refresh wiped _aggregate_nodes. Also from manually executing execute_command('FT.CURSOR', ...) without a preceding FT.AGGREGATE.

Related errors


AI-assisted analysis of redis/redis-py@6a6b581b48 (2026-08-10). Data as JSON: /api/errors/01d29d4eafbf3b5e. Report an issue: GitHub.

Appendix: source

Thrown at redis/asyncio/cluster.py:890

    async def get_nodes_from_slot(self, command: str, *args):
        """
        Returns a list of nodes that hold the specified keys' slots.
        """
        # get the node that holds the key's slot
        return [
            self.nodes_manager.get_node_from_slot(
                await self._determine_slot(command, *args),
                self.read_from_replicas and command in READ_COMMANDS,
                self.load_balancing_strategy if command in READ_COMMANDS else None,
            )
        ]

    def get_special_nodes(self) -> Optional[list["ClusterNode"]]:
        """
        Returns a list of nodes for commands with a special policy.
        """
        if not self._aggregate_nodes:
            raise RedisClusterException(
                "Cannot execute FT.CURSOR commands without FT.AGGREGATE"
            )

        return self._aggregate_nodes

    def keyslot(self, key: EncodableT) -> int:
        """
        Find the keyslot for a given key.

        See: https://redis.io/docs/manual/scaling/#redis-cluster-data-sharding
        """
        return key_slot(self.encoder.encode(key))

    # HIMPORT orchestration (async mirror of redis.cluster.RedisCluster). The one
    # shared HImportRegistry is mutated once by PREPARE/DISCARD/DISCARDALL and applied
    # lazily per node; SET routes by key slot to the owning primary's ClusterNode.
    # See ``.agents/himport_client_support_spec.md``.

View on GitHub (pinned to 6a6b581b48)