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 nolagQuick 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
connecthandler before callingconnect(). 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 themPresence
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:joinandpresence:updateevents. - 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:leaveis 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_typeis 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())error | code | Cause |
|---|---|---|
unknown_topic | 42940 | Subscribe or publish to an address the actor has no grant for |
rate_limit_exceeded | 42910 | More than 50 publishes in one second on this connection |
message_too_large | 42930 | Payload over 921,600 bytes once packed |
not_authorized | none | A lobby the actor may not watch |
not_subscribed | none | set_filters on a topic this connection is not subscribed to |
QoS levels
| Level | Name | Meaning |
|---|---|---|
| 0 | QoS.AT_MOST_ONCE | At most once on the broker hop |
| 1 | QoS.AT_LEAST_ONCE | At least once on the broker hop (default) |
| 2 | QoS.EXACTLY_ONCE | Exactly 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)