Skip to content

Examples

The runnable versions of the two-node demo below live in examples/ (app_node_a.py / app_node_b.py). This page shows the same shape running across different deployment targets, plus two extra patterns (crash recovery, custom types) that don't fit on the Getting started page.

Two local processes

The simplest case: both nodes on 127.0.0.1, different ports.

# terminal 1
NODE_NAME=node_a@127.0.0.1:9001 PEER=node_b@127.0.0.1:9002 python examples/app_node_a.py
# terminal 2
NODE_NAME=node_b@127.0.0.1:9002 PEER=node_a@127.0.0.1:9001 python examples/app_node_b.py

Docker Compose (two containers)

docker-compose.yml in the repository root runs the same two actors as two containers on a bridge network, addressing each other by container name instead of an IP:

services:
  node_a:
    build: .
    command: python app_node_a.py
    environment:
      - NODE_NAME=node_a@node_a:9001
      - PEER=node_b@node_b:9002
    ports: ["9001:9001"]
    networks: [lapinbeam-net]

  node_b:
    build: .
    command: python app_node_b.py
    environment:
      - NODE_NAME=node_b@node_b:9002
      - PEER=node_a@node_a:9001
    ports: ["9002:9002"]
    networks: [lapinbeam-net]

networks:
  lapinbeam-net:
    driver: bridge
docker compose up --build

The only thing that changes versus two local processes is the host part of NODE_NAME/PEER: Docker's embedded DNS resolves node_a/node_b to the right container IP on the bridge network. The CI pipeline (.github/workflows/ci.yml) runs exactly this compose file and asserts node_a's logs show Total: 100 ACKs before tearing it down.

Two more compose files in the repository root exercise the same demo under different conditions, each with its own CI job:

  • docker-compose.secure.yml — the same two containers, but with a matching CLUSTER_SECRET environment variable on both sides, wired through to Node(..., cluster_secret=...) (see Security). Proves the handshake works between two genuinely separate processes, not just within one.
  • docker-compose.restart.yml — a longer-running variant (examples/e2e_restart_node_a.py / e2e_restart_node_b.py) that sends more slowly, giving CI room to restart node_b's container mid-stream and confirm node_a's automatic reconnection actually resumes delivery afterward, instead of just firing a "peer_disconnected" event and going quiet.
docker compose -f docker-compose.secure.yml up --build
docker compose -f docker-compose.restart.yml up --build

Real, separate hosts

See Getting started for a diagram of exactly what "server A" and "server B" mean here — each is its own OS process, and this section just changes their addresses from loopback to real machines. Nothing about lapinbeam is loopback-specific — NodeId is just name@host:port, and host can be any address the other side can route to. Running the two actors on two different machines only changes the environment variables:

# machine at 10.0.0.1
NODE_NAME=node_a@10.0.0.1:9001 PEER=node_b@10.0.0.2:9002 python examples/app_node_a.py
# machine at 10.0.0.2
NODE_NAME=node_b@10.0.0.2:9002 PEER=node_a@10.0.0.1:9001 python examples/app_node_b.py

Two things to plan for once you leave loopback:

  • Firewall the listening port (9001/9002 above) between the hosts — Node.start() binds and accepts from anywhere by default.
  • Expect real network RTT to dominate. The loopback benchmarks isolate lapinbeam's own overhead (sub-millisecond); across real hosts your latency floor is whatever the network between them gives you, plus that overhead on top.

A multi-node pipeline behind an HTTP API

examples/sales_warehouse/ is a bigger, more realistic example than the two-node demo above: a FastAPI order submission fans out across three separate node containers (apifulfillmentarchive), each doing a real network hop to the next, with progress tracked back to the originating node as the order moves through four chained local actors. Its README documents a full CPU/RAM measurement (idle vs. under load) using nothing but docker, docker compose, and uv.

cd examples/sales_warehouse
docker compose up --build

Streaming progress over an HTTP API

examples/order_stream/ is a different three-container setup — postgres, app (FastAPI), and worker (a lapinbeam node) — for a different problem: a client that wants live progress on one order, not just a final result, and can disconnect and reconnect without losing its place. worker runs Supervisor.spawn_pool() for its fixed pool of order-processing workers, and each order's steps are reported back via ActorRef.ask_stream()/reply_stream()/reply_final()app relays that one stream into an in-process pub/sub so any number of browser tabs watching the same order's /orders/{id}/stream get every update, not just whichever request happened to call ask_stream(). See its README for the full walkthrough, including the migration from hand-rolled queues/relays to these primitives.

cd examples/order_stream
docker compose up --build

Node discovery via a seed node

Every example above configures each node with the exact address of every peer it needs — fine for two or three nodes, but that's up to N·(N-1)/2 addresses to hand-configure for a mesh of N. lapinbeam.discovery (see the Features list) turns that into "every node needs one shared seed address": connect to a seed, ask it who it knows, connect to those too, recursively, until nothing new turns up.

from lapinbeam import Node, Supervisor, register_discovery, join_via_seeds

node = Node("app@app:9001")
await node.start()
register_discovery(node, Supervisor(node=node))
found: set[str] = await join_via_seeds(node, seeds=["seed@seed:9000"])

examples/seed_discovery/ runs this across four containers — one seed and three joiners that only ever hear the seed's address — and shows all four converging on a full mesh in the logs:

cd examples/seed_discovery
docker compose up --build

See OTP-inspired patterns for the API and standalone snippets for nested supervision trees, lapinbeam.links, lapinbeam.monitors, lapinbeam.groups, and lapinbeam.registry. examples/cluster_supervision/ runs all five across three real containers — a hub with a nested supervision tree that links and monitors two workers (so the same crash delivers both an Exit and a Down) and watches a cluster-wide "workers" group and a "task_worker_primary" registered name as each worker fails for good:

cd examples/cluster_supervision
docker compose up --build

Recovering from a crash

Supervisor restarts an actor whose receive (or @on handler) raises, using the one_for_one strategy: only the crashed actor is restarted, with exponential backoff, up to max_restarts within restart_window seconds before giving up and re-raising:

import asyncio
from lapinbeam import ActorRef, Node, Supervisor, actor

# Kept outside the actor on purpose — see the note below.
attempts = {"n": 0}


@actor(name="flaky")
class Flaky:
    async def receive(self, msg):
        attempts["n"] += 1
        if attempts["n"] < 3:
            raise RuntimeError(f"transient failure #{attempts['n']}")
        print("succeeded on attempt", attempts["n"])


async def main():
    async with Node("app@127.0.0.1:0") as node:
        sup = Supervisor(strategy="one_for_one", node=node,
                          max_restarts=5, restart_window=10.0)
        ref: ActorRef = sup.spawn(Flaky)
        for _ in range(3):
            await ref.send({})
            # Give the restart (with backoff) time to finish before the next
            # send — a send while the actor is mid-restart briefly has no
            # mailbox to land in and raises ValueError, same as sending to
            # any other name that isn't registered yet.
            await asyncio.sleep(0.4)

Restarts create a fresh instance — state does not survive

Every restart runs actor_cls(*args, **kwargs) again, so anything stored on self (like self.attempts) resets to its initial value on every crash — only the registration (the mailbox and its name) survives, so senders never need to know a restart happened. That's why attempts lives outside the actor above: if it were self.attempts, it would never reach 3 no matter how many times you send, since each crash hands the next message to a brand new instance starting over. If an actor needs state to survive its own crashes, persist it externally (a database, Redis, or — as above — a plain object the actor closes over) rather than on self.

Custom (non-dataclass, non-Pydantic) types

Typed messages covers dataclasses and Pydantic models, which round-trip automatically. Anything else needs an explicit codec registered on both ends of the cluster:

from lapinbeam import register_codec

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __repr__(self):
        return f"Point({self.x}, {self.y})"


register_codec(
    Point,
    encode=lambda p: {"x": p.x, "y": p.y},
    decode=lambda d: Point(d["x"], d["y"]),
)

# From here on, sending a Point works exactly like a dataclass:
# await remote.send(Point(1, 2))

Register the codec once, at import time, on every node that will either send or receive Point instances — the tag lookup on decode needs the codec (or the class itself, if it's a dataclass/Pydantic model) to already be registered/importable.