Python SDK

nolag is the NoLag client for Python 3.10+, built on asyncio. This page documents version 2.6.0. It is MIT licensed.

Installation

# pip
pip install nolag

# poetry
poetry add nolag

# uv
uv add nolag

Quick start

This connects to the local kraken from the quickstart as the demo user alice, whose token grants demo/general/#:

import asyncio
from nolag import NoLag, NoLagOptions

async def main():
    client = NoLag("dev-token-alice", NoLagOptions(url="ws://localhost:8080/ws"))

    # A room context prefixes topics with app/room, so this is demo/general/*
    room = client.set_app("demo").set_room("general")
    room.on("messages", lambda data, meta: print(f"Received: {data}"))

    # 'connect' fires after every successful connection, including
    # reconnects. Handlers are plain functions, so schedule the coroutine.
    client.on("connect", lambda: asyncio.ensure_future(room.subscribe("messages")))

    await client.connect()
    await room.emit("messages", {"hello": "world"})

    # Keep running
    await asyncio.sleep(60)

    # Disconnect when done (synchronous)
    client.disconnect()

asyncio.run(main())

Always pass NoLagOptions(url=...): the built-in default URL points at the retired NoLag Cloud broker.

Topics live inside rooms. Every address is app/room/topic. Topic names are single tokens with no /. An address only works if the actor's grants cover it: grants come from your kraken auth configuration, either a static auth.json (Static auth) or a @nolag/core project (Project document). An address the actor has no grant for is refused with unknown_topic (42940) on the error event. See Topics and Rooms.

Configuration

from nolag import NoLag, NoLagOptions, QoS

options = NoLagOptions(
    url="ws://localhost:8080/ws",       # Your kraken's WebSocket URL (always set this)
    reconnect=True,                     # Reconnect automatically
    reconnect_interval=5.0,             # Seconds between reconnect attempts
    max_reconnect_attempts=10,          # Attempts before giving up (0 never reconnects)
    heartbeat_interval=30.0,            # Heartbeat in seconds (0 disables)
    qos=QoS.AT_LEAST_ONCE,              # Default QoS level
    load_balance=False,                 # Load-balance every subscription
    load_balance_group=None,            # Group name (default: the actor's id)
    debug=True,                         # Log protocol traffic via the "nolag" logger
)

client = NoLag("dev-token-alice", options)

The token is a string. Version 2.6.0 has no token provider and does not refresh client tokens, so give long-running Python services an actor token that does not expire. Keep the heartbeat on: kraken closes a socket that has been idle for 60 seconds.

Reconnects and subscriptions

The client reconnects by itself after a dropped connection, every reconnect_interval seconds, and gives up after max_reconnect_attempts consecutive failures. It does not keep a list of your subscriptions, and kraken restores subscriptions only when your auth backend supplies them (the static auth file never does), so treat every connection as starting empty:

  • Register a connect handler before calling connect(). It runs after every successful connection, the first one and each reconnect.
  • Subscribe inside it. Event handlers are called synchronously, so schedule the coroutine with asyncio.ensure_future().
  • Topic handlers registered with on() are local and survive reconnects; register them once.

Room presence is different: the SDK re-sends the last presence you set, in its room, after every reconnect, so you do not need to set it again.

import asyncio
from nolag import NoLag, NoLagOptions, SubscribeOptions

async def main():
    client = NoLag("dev-token-alice", NoLagOptions(url="ws://localhost:8080/ws"))
    room = client.set_app("demo").set_room("general")

    room.on("messages", lambda data, meta: print(data))

    async def resubscribe():
        await room.subscribe("messages", SubscribeOptions(filters=["vip"]))

    client.on("connect", lambda: asyncio.ensure_future(resubscribe()))
    client.on("disconnect", lambda reason: print(f"Disconnected: {reason}"))
    client.on("reconnect", lambda: print("Reconnected"))

    await client.connect()
    await room.set_presence({"username": "alice", "status": "online"})
    await asyncio.Event().wait()

asyncio.run(main())

reconnect fires after a reconnect has succeeded, just after connect. With the default broker, messages published while a client is disconnected are not delivered to it later. See Running in production for what a reconnect does and does not restore.

Fluent API

The room context saves you repeating the full topic path:

room = client.set_app("demo").set_room("general")

# Subscribe, listen and publish within the room
await room.subscribe("messages")
room.on("messages", lambda data, meta: print(data))
await room.emit("messages", {"text": "Hello from general!"})

# The same, with full topic paths
await client.subscribe("demo/general/messages")
client.on("demo/general/messages", lambda data, meta: print(data))
await client.emit("demo/general/messages", {"text": "Hello!"})

# Filters and presence on the room
await room.set_filters("messages", ["lang:en", "lang:fr"])
await room.set_presence({"status": "online"})
members = await room.fetch_presence()

print(room.prefix)  # "demo/general"

await room.unsubscribe("messages")

Subscribing to topics

subscribe() registers interest in a topic; on() attaches the message handler. Subscribe only on a connected client: on a disconnected one, subscribe() returns without sending anything (and calls callback with a Not connected error if you passed one).

from nolag import SubscribeOptions, QoS

room = client.set_app("demo").set_room("general")

# Basic subscription and handler
await room.subscribe("messages")
room.on("messages", lambda data, meta: print(data))

# With options (QoS, load balancing, filters)
await room.subscribe("messages", SubscribeOptions(
    qos=QoS.AT_LEAST_ONCE,
    load_balance=True,
    load_balance_group="workers",
    filters=["priority:high", "region:us"],
))

# With an acknowledgement callback: called with None on kraken's
# 'subscribed' frame, or with the error that refused the subscription
def on_subscribed(err):
    if err:
        print("Subscribe failed:", err)
    else:
        print("Subscribed")

await room.subscribe("messages", callback=on_subscribed)

await room.unsubscribe("messages")

Subscribing again to a topic you already hold replaces the old subscription, so it is safe to re-run your subscribe code.

Publishing messages

from nolag import EmitOptions, QoS

# Publish any MessagePack-encodable data (dict, list, str, bytes, numbers)
await room.emit("messages", {"text": "Hello!"})

# With options
await room.emit("messages", {"online": True}, EmitOptions(
    qos=QoS.AT_LEAST_ONCE,
    retain=True,            # Keep the last message for later subscribers
    filter="priority:high", # Route to subscribers filtering on this value
    echo=False,             # Do not deliver this message back to this connection
))

# With an acknowledgement callback: called on kraken's 'published' frame
await room.emit("messages", {"text": "Hello"}, callback=lambda err: print(err or "sent"))

You receive your own messages. kraken delivers a publish to every subscriber of the topic, including the connection that sent it. Pass EmitOptions(echo=False) to have kraken drop it for the sending connection only; another connection of the same actor still receives it.

A payload may be at most 921,600 bytes once packed, and kraken limits each connection to 50 publishes per second.

Filters

Publishers tag a message with a filter value; subscribers choose the values they want:

from nolag import SubscribeOptions, EmitOptions

room = client.set_app("demo").set_room("general")

# Subscribe with initial filters
await room.subscribe("messages", SubscribeOptions(filters=["region:us", "priority:high"]))
room.on("messages", lambda data, meta: print(data, meta.filter))

# Replace, add or remove filters on the existing subscription
await room.set_filters("messages", ["region:eu"])
await room.add_filters("messages", ["priority:critical"])
await room.remove_filters("messages", ["region:eu"])

# Clear all filters (receive everything on the topic again)
await room.set_filters("messages", [])

# Publish with a filter value
await room.emit("messages", {"msg": "US high priority"}, EmitOptions(filter="region:us"))

Filters are routing, not privacy. A subscription without filters receives every message on the topic, filtered publishes included. A subscription with filters receives only publishes whose filter matches. At most 100 filters per topic, and values must not contain /, #, + or |. Nested lists express AND groups. See Filters.

Connection events

from nolag import ConnectionStatus

client.on("connect", lambda: print("Connected"))
client.on("disconnect", lambda reason: print(f"Disconnected: {reason}"))
client.on("reconnect", lambda: print("Reconnected"))

# Broker errors arrive as NoLagServerError (see Error handling)
client.on("error", lambda err: print(f"Error: {err}"))

# The app's hydration webhook response, once per subscribe.
# `topic` is the bare topic name, for example "messages".
client.on("hydration", lambda topic, data: print(f"Initial state for {topic}: {data}"))

if client.status == ConnectionStatus.CONNECTED:
    print("Connected as", client.actor_id)

See Webhooks for configuring hydration.

Every message (on_any)

on_any() receives every topic message with (topic, data, meta), where topic is the full app/room/topic path:

def log_all(topic, data, meta):
    print(f"[{topic}] {data}")

client.on_any(log_all)

client.off_any(log_all)  # remove one handler
client.off_any()         # remove all of them

Presence

Presence belongs to a room. Set it through the room context; presence events arrive on the client:

room = client.set_app("demo").set_room("general")

# Presence events are client-level and carry an ActorPresence
client.on("presence:join", lambda actor: print(f"Joined: {actor.actor_token_id} {actor.presence}"))
client.on("presence:update", lambda actor: print(f"Updated: {actor.presence}"))
client.on("presence:leave", lambda actor: print(f"Left: {actor.actor_token_id}"))

# Set presence in the room (re-sent automatically after reconnects)
await room.set_presence({"status": "online", "username": "alice"})

# The same, naming the room slug explicitly
await client.set_presence({"status": "online"}, room_id="general")

# The room's list from kraken: dicts with actorTokenId, presence and joinedAt
for entry in await room.fetch_presence():
    print(entry["actorTokenId"], entry["presence"])

# Local cache, built from presence events
for actor in client.get_all_presence():
    print(actor.actor_token_id, actor.presence)

Things to know:

  • A connection is present in one room at a time: setting presence in another room leaves the first. You also receive your own presence:join and presence:update events.
  • kraken resolves the room by its slug in the actor's grants and silently ignores presence for a room it cannot resolve. See Presence.
  • The local cache only learns about actors from events, and fetch_presence() does not fill it. presence:leave is emitted only for actors this client has already seen join or update.
  • clear_presence() stops the SDK re-sending your presence and sends empty presence data. It does not take you out of the room's presence list; only disconnecting does.
  • ActorPresence.actor_type is not sent by kraken; ignore it.

Lobbies

A lobby lets one client watch presence across several rooms:

# The actor needs a grant for the lobby (see Lobbies)
lobby = client.set_app("demo").set_lobby("online")

# A snapshot keyed by room slug, then actor id
snapshot = await lobby.subscribe()
print("Rooms:", list(snapshot.keys()))

# Events carry lobby_id, room_id, actor_id and data
lobby.on("presence:join", lambda e: print(f"{e.actor_id} joined {e.room_id}"))
lobby.on("presence:leave", lambda e: print(f"{e.actor_id} left {e.room_id}"))

latest = await lobby.fetch_presence()
lobby.unsubscribe()

A lobby the actor may not watch is refused with not_authorized on the error event, and subscribe() then raises a timeout after 10 seconds. See Lobbies for how kraken decides which rooms a lobby covers.

Error handling

Broker-side failures are asynchronous: they arrive on the error event as NoLagServerError with error (the machine name), code, topic, hint and msg_ref, and also settle the callback of the subscribe or publish that caused them. Local failures (a closed socket, a payload that cannot be encoded) raise from the call. Register the error handler before connect().

import asyncio
from nolag import NoLag, NoLagOptions, NoLagServerError, NoLagEncodeError

def on_error(err):
    if isinstance(err, NoLagServerError):
        print(f"{err.error} ({err.code}) on {err.topic}: {err.hint}")
        if err.code == 42940:
            pass  # unknown_topic: no grant covers this address
    elif isinstance(err, NoLagEncodeError):
        print(f"Cannot encode payload for {err.op}: {err}")
    else:
        print(f"Error: {err}")

async def main():
    client = NoLag("dev-token-alice", NoLagOptions(url="ws://localhost:8080/ws"))
    client.on("error", on_error)

    try:
        await client.connect()
    except Exception as e:
        # The broker's reason, for example "access_denied"
        print(f"Connection failed: {e}")
        return

    room = client.set_app("demo").set_room("general")
    try:
        await room.emit("messages", {"data": "value"})
    except Exception as e:
        print(f"Emit failed: {e}")

    client.disconnect()

asyncio.run(main())
errorcodeCause
unknown_topic42940Subscribe or publish to an address the actor has no grant for
rate_limit_exceeded42910More than 50 publishes in one second on this connection
message_too_large42930Payload over 921,600 bytes once packed
not_authorizednoneA lobby the actor may not watch
not_subscribednoneset_filters on a topic this connection is not subscribed to

QoS levels

LevelNameMeaning
0QoS.AT_MOST_ONCEAt most once on the broker hop
1QoS.AT_LEAST_ONCEAt least once on the broker hop (default)
2QoS.EXACTLY_ONCEExactly once on the broker hop

QoS is not an end-to-end guarantee. kraken validates the level and hands it to its broker backend. The built-in broker delivers in memory and does not use it; with an external MQTT broker it applies to kraken's hop to that broker. The WebSocket leg has no resend at any level. See QoS.

Load balancing

Without load balancing every subscriber receives every message. With it, subscriptions that share a group each get a share: every message goes to exactly one member.

from nolag import NoLag, NoLagOptions, SubscribeOptions

# For every subscription on this client
client = NoLag("dev-token-alice", NoLagOptions(
    url="ws://localhost:8080/ws",
    load_balance=True,
    load_balance_group="task-workers",
))

# Or per subscription
room = client.set_app("demo").set_room("general")
await room.subscribe("messages", SubscribeOptions(
    load_balance=True,
    load_balance_group="task-workers",
))
room.on("messages", lambda data, meta: print(f"Processing: {data}"))

Without a group name, the group is the actor's id. kraken scopes group names to the project and app, so the same name in two apps never collides.

Exports

from nolag import (
    NoLag,              # The client
    NoLagOptions,       # Connection options
    SubscribeOptions,   # Subscription options
    EmitOptions,        # Publish options
    ConnectionStatus,   # Connection status enum
    ActorType,          # Actor type enum (client.actor_type after connect)
    QoS,                # QoS level enum
    MessageMeta,        # msg_id and filter of a received message
    ActorPresence,      # Presence entry
    LobbyPresenceEvent, # Lobby presence event
    NoLagServerError,   # Structured broker error (error, code, topic, hint, msg_ref)
    NoLagEncodeError,   # Payload could not be MessagePack-encoded
)

Requirements

  • Python 3.10+
  • websockets 12.0 or later
  • msgpack 1.0.0 or later
  • aiohttp 3.9.0 or later (installed with the package)

Next steps