Skip to content

Client + Cluster

In this guide you'll connect to a running cluster from outside using ClusterClient. Along the way you'll learn how the client discovers topology, how entity_ref creates a local proxy for routing, and how ask() enables request-reply from external code.

When to Use ClusterClient

ClusteredActorSystem joins the cluster as a full member — it runs actors, owns shards, participates in gossip. ClusterClient is for code that uses the cluster without joining it: API servers, CLI tools, monitoring dashboards. It connects via TCP, subscribes to topology updates, and routes messages directly to the node owning each shard.

Messages and Entity Factory

A loyalty points system. Each user is a sharded entity:

@dataclass(frozen=True)
class AddPoints:
    amount: int


@dataclass(frozen=True)
class GetPoints:
    reply_to: ActorRef[int]


type LoyaltyMsg = AddPoints | GetPoints


NUM_SHARDS = 10


# ── Entity factory ───────────────────────────────────────────────────


def loyalty_entity(entity_id: str) -> Behavior[LoyaltyMsg]:
    def active(points: int = 0) -> Behavior[LoyaltyMsg]:
        async def receive(_ctx: Any, msg: LoyaltyMsg) -> Behavior[LoyaltyMsg]:
            match msg:
                case AddPoints(amount):
                    print(f"  [{entity_id}] +{amount} points (total: {points + amount})")
                    return active(points + amount)
                case GetPoints(reply_to):
                    reply_to.tell(points)
                    return Behaviors.same()

        return Behaviors.receive(receive)

    return active()

Starting the Cluster

A single-node cluster with a sharded "loyalty" entity type:

async def main() -> None:
    # 1. Start a single-node cluster
    cluster = ClusteredActorSystem(
        name="loyalty-cluster",
        host="127.0.0.1",
        port=25530,
        node_id="node-1",
    )

    async with cluster:
        cluster.spawn(
            Behaviors.sharded(loyalty_entity, num_shards=NUM_SHARDS),
            "loyalty",
        )
        await cluster.wait_for(1)
        print("── Cluster running ──\n")

Connecting from Outside

ClusterClient takes a list of contact points and the cluster's system name:

# 2. Connect from outside with ClusterClient
async with ClusterClient(
    contact_points=[("127.0.0.1", 25530)],
    system_name="loyalty-cluster",
) as client:
    await asyncio.sleep(1.0)

The client connects to the contact point, subscribes to TopologySnapshot updates, and caches shard allocations locally. entity_ref("loyalty", num_shards=NUM_SHARDS) creates a local proxy actor that routes ShardEnvelope messages to the cluster.

Fire and Forget

tell() works the same as inside the cluster — wrap the message in ShardEnvelope:

async def send_points(loyalty: ActorRef[ShardEnvelope[LoyaltyMsg]]) -> None:
    print("── Sending points ──")
    loyalty.tell(ShardEnvelope("user-1", AddPoints(100)))
    loyalty.tell(ShardEnvelope("user-1", AddPoints(50)))
    loyalty.tell(ShardEnvelope("user-2", AddPoints(200)))
    await asyncio.sleep(0.5)

Request-Reply

client.ask() creates a temporary ref, sends the message, and waits for the response:

async def query_points(
    client: ClusterClient, loyalty: ActorRef[ShardEnvelope[LoyaltyMsg]]
) -> None:
    print("\n── Querying points ──")
    for user in ("user-1", "user-2"):
        points: int = await client.ask(
            loyalty,
            lambda r, uid=user: ShardEnvelope(uid, GetPoints(reply_to=r)),
            timeout=5.0,
        )
        print(f"  {user}: {points} points")

The reply travels back through the existing TCP connection — no extra server socket or reverse tunnel needed.

Running It

async def main() -> None:
    # 1. Start a single-node cluster
    cluster = ClusteredActorSystem(
        name="loyalty-cluster",
        host="127.0.0.1",
        port=25530,
        node_id="node-1",
    )

    async with cluster:
        cluster.spawn(
            Behaviors.sharded(loyalty_entity, num_shards=NUM_SHARDS),
            "loyalty",
        )
        await cluster.wait_for(1)
        print("── Cluster running ──\n")

        # 2. Connect from outside with ClusterClient
        async with ClusterClient(
            contact_points=[("127.0.0.1", 25530)],
            system_name="loyalty-cluster",
        ) as client:
            await asyncio.sleep(1.0)

            loyalty = client.entity_ref("loyalty", num_shards=NUM_SHARDS)

            await send_points(loyalty)
            await query_points(client, loyalty)


asyncio.run(main())

Output:

── Cluster running ──

── Sending points ──
  [user-1] +100 points (total: 100)
  [user-1] +50 points (total: 150)
  [user-2] +200 points (total: 200)

── Querying points ──
  user-1: 150 points
  user-2: 200 points

Run the Full Example

git clone https://github.com/gabfssilva/casty.git
cd casty
uv run python examples/guides/07_cluster_client.py

What you learned:

  • ClusterClient connects to a cluster without joining it — topology-aware routing with zero membership.
  • entity_ref(shard_type, num_shards=N) returns a cached proxy that routes ShardEnvelope to the correct node.
  • client.ask() enables request-reply from outside the cluster using temporary refs.
  • Fault tolerance is built in — the client rotates contact points on timeout and caches topology locally.