Python · WebSocket client

aiohttp_ws_client

Connect to a Salus WebSocket endpoint, route named events, exchange request/response messages, and reconstruct chunked payloads.

This guide documents the current distributed modules/aiohttp_ws_client.py. This file declares no __version__. It speaks the JSON event protocol of aiohttp_ws.WSEndpoint, which differs from the raw-text PmSocket protocol.

What the client does

Named events

Register coroutine handlers by function name, then receive decoded WSMessage objects for matching events.

Request/response

Attach a generated response_id, wait for a matching RESPONSE, and return the response message.

Typed payloads

Send strings, numbers, bytes, dictionaries, or lists. Bytes are base64 encoded; JSON values remain structured.

Reconnect attempts

After a connection ends, retry a configurable number of times with a fixed delay between attempts.

Async handlers only Event handlers, request handlers, and on_connect are awaited or scheduled as coroutines. Define them with async def.

Quick start

Install aiohttp and make toolbox importable; the current file imports both. Create your own client with a ws:// or wss:// URL and register handlers before connecting. Importing also constructs a module-level demo client without starting it; its demo callback reads a local PDF if that demo client is later connected. Use a new Client instance, as below.

from aiohttp_ws_client import Client

client = Client("wss://example.com/ws")

@client.event()
async def notification(message):
    print("notification:", message.data)

@client.r_event()
async def add(message):
    left = message.data["left"]
    right = message.data["right"]
    return {"total": left + right}

async def connected(active_client):
    response = await active_client.send_req(
        {"left": 2, "right": 3},
        "add",
        timeout=2_000,
    )
    if response is None:
        print("request timed out")
    else:
        print(response.data)

client.on_connect = connected
client.run()
Handler names are protocol names The decorator uses the Python function’s __name__ as the event key. A function named notification handles the wire event "notification"; matching is case-sensitive.

When an event loop already exists

In an async application, await websocket() from your own loop instead of calling the blocking run() helper.

import asyncio
from aiohttp_ws_client import Client

async def main():
    client = Client("wss://example.com/ws")
    await client.websocket(headers={"Authorization": "Bearer …"})

asyncio.run(main())

Events and request/response messages

The dispatcher selects one of three paths after decoding and, when needed, reconstructing a message:

  1. Response: an event named RESPONSE with a known response_id completes a pending send_req().
  2. Incoming request: any other message with a response_id is sent to a matching r_event handler. Its return value is sent back as RESPONSE.
  3. One-way event: a message without response_id is sent to a matching event handler.

One-way event

@client.event()
async def account_updated(message):
    account = message.data
    print(account["id"])

Request handler

@client.r_event()
async def lookup_account(message):
    account_id = message.data["account_id"]
    return {"id": account_id, "status": "active"}

The returned value must be accepted by send_msg(): str, int, float, bytes, dict, or list.

Make a request

reply = await client.send_req(
    {"account_id": 42},
    "lookup_account",
    check_interval=10,  # milliseconds
    timeout=2_000,      # milliseconds
)

if reply is None:
    print("No reply arrived before the deadline")
else:
    print(reply.event, reply.data)
Timeouts return None send_req() does not raise a timeout exception. It polls the pending response table and returns None if no response arrives in time.

Wire protocol

Every logical message is sent as a JSON text frame with three required keys.

{
  "event": "account_updated",
  "type": "json",
  "data": {"id": 42, "status": "active"}
}
FieldRequiredMeaning
eventYesCase-sensitive handler name. RESPONSE is reserved for replies.
typeYesstring, bytes, or json.
dataYesThe encoded payload. Bytes use base64; JSON data is embedded directly.
response_idNoCorrelates a request with its response.
construct_idNoGroups frames belonging to one chunked message.
construct_indexWith constructZero-based chunk order.
construct_finalWith constructMarks the final chunk and triggers reconstruction.

Python value encoding

Python inputWire typeWire dataDecoded message.data
strstringStringstr
int, floatstringString formstr
bytesbytesBase64 stringbytes
dict, listjsonJSON object or arraydict or list

Other input types raise TypeError. Booleans are not accepted because the implementation checks exact class names rather than using normal numeric subtype behavior.

Chunked messages

_stream_msg() divides a large logical payload into messages with a shared construct ID. The receiver buffers chunks, sorts them by index, and dispatches only after the final chunk arrives.

await client._stream_msg(
    large_payload,
    "archive_uploaded",
    chunk_size=100 * 2**10,
)
Private and fragile API The leading underscore marks _stream_msg() as internal. The current implementation has inconsistent byte/string chunk handling; test it against the exact server version before depending on it.

Connection lifecycle and reconnects

  1. websocket() creates an aiohttp.ClientSession and connects with max_msg_size.
  2. It stores the active socket in client.ws, marks the client connected, and schedules on_connect(client).
  3. Each incoming frame passes to check(). Handler exceptions are printed and the receive loop continues.
  4. When the socket closes, the client waits re_dur seconds and retries up to re_ret times.
  5. After retries are exhausted, re_failed becomes true and the connection ends.

A reconnected socket that lasts fewer than 20 seconds is treated as an unstable reconnect and raises TimeoutError into the retry loop.

on_connect runs in its own task The receive loop does not wait for it to finish. Handle exceptions inside the callback if you need deterministic reporting.

Ping

ping() uses the application-level PING request handler installed by the constructor. It is not an aiohttp WebSocket control-frame ping.

latency_ms = await client.ping(
    msg_size=128,
    trial_count=5,
    send_as_bytes=False,
    timeout=1_000,
)
print(f"Average: {latency_ms} ms")

Configuration and state

AttributeDefaultPurpose
urlConstructor valueWebSocket URL passed to ws_connect().
max_msg_size4_194_304_000Maximum aiohttp message size in bytes.
re_ret2Reconnect attempts after a disconnect.
re_dur5Seconds to wait before each reconnect attempt.
re_logTruePrint connection and reconnect status.
connectedFalseTrue while the socket receive loop is active.
reconnectingFalseTrue after a disconnect while retrying.
reconnectedFalseRecords that at least one reconnection succeeded.
re_failedFalseStops further retries after failure or shutdown logic sets it.
wsNoneCurrent aiohttp WebSocket connection.
events, r_eventsMappingsRegistered one-way and request handler functions.
reqs, constructsMappingsInternal pending-response and chunk-reassembly state.
client = Client("wss://example.com/ws")
client.max_msg_size = 16 * 2**20
client.re_ret = 5
client.re_dur = 2
client.re_log = False

Client API reference

The methods below reflect the historical client snapshot. Methods without a leading underscore are presented as public, except where a caveat is explicitly noted.

Constructor

Client(url)
url
WebSocket URL, normally beginning with ws:// or wss://.
Returns
A disconnected client with empty handler and pending-message registries.

event()

client.event()

Returns a decorator that registers a one-way event handler under the decorated function’s name.

r_event()

client.r_event()

Returns a decorator that registers a request handler. The handler’s return value is automatically sent as a RESPONSE.

on_connect

client.on_connect = async_callable

A property for the callback scheduled after each successful connection. Assigning a non-callable raises TypeError.

run()

client.run()

Schedules websocket() on the current event loop and calls run_forever(). This blocks the calling thread and is intended for a standalone process.

websocket()

await client.websocket(headers={})
headers
Headers supplied to the new aiohttp.ClientSession.
Returns
None after the connection and reconnect lifecycle finishes.

send_msg()

await client.send_msg(data, event, msg_dict=None)
data
Supported Python payload.
event
Case-sensitive event name.
msg_dict
Optional metadata mapping mutated in place with event, type, and data.
Returns
None. If no socket exists, it silently sends nothing.

send_req()

await client.send_req(data, event, check_interval=10, timeout=2000)
check_interval
Polling interval in milliseconds.
timeout
Total wait in milliseconds.
Returns
A matching WSMessage, or None when no socket is assigned or the wait times out. A reset invokes the broken close() path described below and can raise instead.

check()

await client.check(raw_msg)

Internal dispatch entry point for an aiohttp message. Returns True if it buffered or handled the message and False if parsing failed or no handler matched.

ping()

await client.ping(msg_size=0, trial_count=1, send_as_bytes=False, timeout=1000, interval=.5)

Sends repeated application-level PING requests and returns the average elapsed milliseconds. It does not check whether a reply arrived, so a measured duration can represent timeouts. Despite its name, interval is passed as the response polling interval.

close()

await client.close()

Intended to close the active socket and clear client state. The current implementation references undefined names; see Known caveats before calling it.

_stream_msg()

await client._stream_msg(data, event, chunk_size=102400)

Private helper that splits a payload into construct frames. Treat it as experimental and test interoperability for the payload type you use.

WSMessage API reference

WSMessage is the decoded object supplied to handlers and returned by successful requests.

AttributeMeaning
msg_typeOriginal aiohttp.WSMsgType.
msgOriginal aiohttp message, when created with from_msg().
eventDecoded event name.
typeProtocol payload type: string, bytes, or json.
dataDecoded application payload.
response_idRequest correlation ID, if present.
construct_idChunk group ID, if present.
construct_indexChunk position.
construct_finalWhether this is the final chunk.
WSMessage.from_msg(msg)

Builds a message from an aiohttp WebSocket text message. Non-text frames produce a mostly empty WSMessage.

WSMessage.from_msg_dict(msg_dict)

Builds a message from an already decoded protocol dictionary. The current construct-ID assignment contains a key mix-up; see the caveats below.

WSMessage.construct_msg(msg_list)

Sorts chunks by construct_index, joins their data, and decodes the reconstructed value according to the first chunk’s type.

WSMessage.json_load(s)

Thin wrapper around json.loads(s).

Timestamp helpers

ts(dt=None) returns dt.timestamp(), using a naive datetime.utcnow() when omitted. Since timestamp() interprets a naive datetime as local time, the default can be offset on non-UTC hosts; pass an aware UTC datetime for accurate timestamps. from_ts(t) calls datetime.fromtimestamp(t), which returns local time for the host environment.

Client and message objects

A Client owns connection state and callback dictionaries. It creates a native aiohttp.ClientWebSocketResponse in client.ws when websocket() connects. Each received text envelope becomes a separate WSMessage. User handlers receive that message only, so capture the owning client in a closure when sending a reply or another request.

Object/memberArguments and resultState and usage
Client(url)Synchronous constructor; returns a disconnected client.Does not connect. Set max_msg_size, re_ret, re_dur, callbacks, and event handlers before starting.
client.event() / client.r_event()Return decorators; each decorator accepts an async function and returns it.Keyed by the function's case-sensitive name. Re-registering a name replaces the stored callback. Request handlers must return a supported payload; ordinary event return values are ignored.
client.on_connectAssign an async callable accepting this Client.Scheduled after each successful connection, rather than awaited by the receive loop. The property rejects non-callables but does not itself check coroutine status.
client.websocket(headers={})Async, long-lived operation returning None after its receive/retry lifecycle.Creates a session and sets ws/connected. Use an application task in an existing loop. The current reconnect and session-cleanup limitations still apply.
client.send_msg(data, event, msg_dict=None)Async; normally returns None.Encodes and sends immediately. If ws is None, returns without sending. If provided, msg_dict is mutated with event, type, and data; do not reuse it across concurrent sends.
client.send_req(data, event, check_interval=10, timeout=2000)Async; returns a decoded WSMessage or None on timeout/no assigned socket.Creates a correlation ID in reqs, sends, then polls until a reply arrives or the timeout expires. Both timing arguments are milliseconds. A send failure can raise; it is not a guaranteed-None operation.
client.check(raw_msg)Async; returns True when buffered/dispatched, otherwise False.Normally owned by the receive loop. Populates reqs for replies and constructs for partial messages. It awaits application handlers directly.
client.ping(msg_size=0, trial_count=1, send_as_bytes=False, timeout=1000, interval=.5)Async; returns average elapsed milliseconds as a number.Uses the built-in PING event and does not establish that every trial received a reply. Use send_req(..., "PING") directly when success/failure matters.
WSMessage()Synchronous construction of an empty message.Most applications use messages supplied by callbacks. Relevant fields are data, event, type, response_id, and the original msg/msg_type where available.
WSMessage.from_msg_dict(envelope) / WSMessage.from_msg(raw)Call on the class; return a new decoded object synchronously.The former accepts the protocol dictionary; the latter accepts an aiohttp message. Required dictionary fields are event, type, and data. Invalid input may raise; Client.check() catches initial decode errors.
WSMessage.construct_msg(parts)Call on the class; returns the combined message.Sorts the supplied list in place by construct_index. Existing chunk-format limitations apply; ordinary send_msg() avoids this experimental path.

connected describes the receive-loop state; ws can still refer to a closed socket after a disconnect. Check client.ws is not None and not client.ws.closed before low-level operations. reqs and constructs are implementation state, not application storage.

Client workflows

1. Decode a structured message without a connection

Use the class factory to inspect the same object shape an event handler receives. Ordinary messages do not require construct fields.

from aiohttp_ws_client import WSMessage

message = WSMessage.from_msg_dict({
    "event": "RESULT", "type": "json",
    "data": {"count": 3, "ready": True},
    "response_id": "request-1",
})
print(message.event)           # RESULT
print(message.data["count"])   # 3
print(message.response_id)     # request-1

2. Start a request from an event without blocking replies

The server for this example must implement a LOOKUP request event. The event callback schedules work and returns so the receive loop can process the reply. Retain task references until completion and handle request errors inside the task.

import asyncio
from aiohttp_ws_client import Client

client = Client("wss://example.com/ws")
jobs = set()

async def fetch_details(item_id):
    try:
        reply = await client.send_req({"id": item_id}, "LOOKUP", timeout=3000)
        if reply is None:
            print("No details received")
        else:
            print(reply.data)
    except Exception as error:
        print("Lookup failed:", error)

@client.event()
async def ITEM_CHANGED(message):
    task = asyncio.create_task(fetch_details(message.data["id"]))
    jobs.add(task)
    task.add_done_callback(jobs.discard)

client.run()

3. Handle binary payloads and stop deliberately

Bytes passed to send_msg() use base64 in the JSON envelope and arrive decoded. This callback accepts a file fragment, acknowledges its byte count, then closes the current socket while disabling reconnect. It works around the module's broken Client.close(); the separate unclosed-session limitation remains.

from aiohttp_ws_client import Client

client = Client("wss://example.com/ws")
client.max_msg_size = 1024 * 1024

@client.event()
async def FILE_FRAGMENT(message):
    if message.type != "bytes":
        return
    fragment = message.data
    await client.send_msg({"received_bytes": len(fragment)}, "ACK")
    client.re_failed = True
    if client.ws is not None and not client.ws.closed:
        await client.ws.close()

client.run()

Troubleshooting

My handler never runs

Confirm that the wire event exactly matches the decorated function name, including case. Use @client.event() for messages without response_id and @client.r_event() for messages that contain one.

send_req() always returns None

Verify the client is connected, the remote handler sends an event named RESPONSE, and the response preserves the original response_id. Increase timeout if the handler takes longer than two seconds.

My number arrives as a string

This is expected: int and float inputs use the protocol type string. Wrap numeric data in a dictionary or list if you need to preserve JSON number types.

The client reconnects and immediately fails again

A successful reconnect lasting less than 20 seconds is treated as unstable. Check server logs, authentication expiry, proxy idle limits, and whether the endpoint accepts the same headers on reconnect.

A malformed message disappears

check() catches decoding failures and returns False. The receive loop also catches handler exceptions, prints a traceback, and continues. Capture standard error in production so these failures remain visible.

Known caveats in the current module

Review before production use These are implementation behaviors, not hypothetical concerns. The guide calls them out so maintainers can make an informed compatibility decision.
  • close() checks self.closed even though the constructor does not initialize it, and references ws instead of self.ws. It can raise AttributeError or NameError.
  • Reconnects call websocket() without the original headers, so authentication headers from the initial call are not preserved. The initial connection attempt occurs outside the retry loop and may raise directly.
  • websocket(headers={}) uses a mutable default argument. The function does not currently mutate it, but callers should still pass an explicit dictionary.
  • The ClientSession is not wrapped in async with or explicitly closed, which can produce unclosed-session warnings.
  • The receive loop awaits event handlers directly. If such a handler awaits send_req() on the same client, the loop cannot read the reply until that handler returns. Schedule outbound request work in a separate task, as the built-in on_connect path does.
  • send_req() polls shared state instead of awaiting a future; very small or invalid check_interval values can create division errors or ineffective timeouts.
  • ping(interval=.5) passes .5 as milliseconds, which truncates timeout / check_interval and sleeps for 0.0005 seconds per poll.
  • send_msg() prints every serialized outgoing message, which may expose payloads in logs.
  • _stream_msg() and construct_msg() disagree on some text/byte joins in the current module. Validate chunked messages end to end.
  • WSMessage.from_msg_dict() assigns construct_id from response_id instead of construct_id.
  • The generated reference previously listed a module-level on_connect(client) function. That function is demo code that reads a local PDF and is not part of the reusable client API.
  • The client accepts an unusually large default message size (about 3.9 GiB). Set a realistic limit to reduce memory-exhaustion risk.

Complete source API

Generated from modules/aiohttp_ws_client.py; no module version is declared. Includes public functions, classes, directly declared methods, properties, and Python protocol methods. Conditional APIs may require optional dependencies. Inherited members and dynamically assigned attributes are explained in the guide where relevant.

Signatures and docstrings are extracted without importing or running the module. A property or AsyncProperty decorator changes how a member is accessed; see its decorators and the guide.

Classes and objects

Module functions

ts(dt=None)

Source line 47

ParameterPassing conventionDefault / required
dtpositional or keywordNone
from_ts(t)

Source line 51

ParameterPassing conventionDefault / required
tpositional or keywordrequired
class Client

Source line 57

Construct: Client(url)

Fields assigned by the constructor: connected, constructs, default_status, error_handlers, events, max_msg_size, r_events, re_dur, re_failed, re_log, re_ret, reconnected, reconnecting, reqs, routes, url, ws, ws_endpoints. Some assignments may be conditional; see the object guide for meaning and lifecycle.

Declared functions, properties, and nested objects:

Client.__init__(self, url)

Source line 58

ParameterPassing conventionDefault / required
urlpositional or keywordrequired
Client.r_event(self)

Source line 88

No caller-supplied parameters are declared.

Client.event(self)

Source line 93

No caller-supplied parameters are declared.

Client.on_connect(self)

Source line 100

Decorators: @property

No caller-supplied parameters are declared.

Client.on_connect(self, value)

Source line 103

Decorators: @on_connect.setter

ParameterPassing conventionDefault / required
valuepositional or keywordrequired
Client.run(self)

Source line 108

No caller-supplied parameters are declared.

async Client.websocket(self, headers={})

Source line 113

ParameterPassing conventionDefault / required
headerspositional or keyword{}

Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.

async Client.close(self)

Source line 168

No caller-supplied parameters are declared.

Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.

async Client.send_req(self, data, event, check_interval=10, timeout=2000)

Source line 174

ParameterPassing conventionDefault / required
datapositional or keywordrequired
eventpositional or keywordrequired
check_intervalpositional or keyword10
timeoutpositional or keyword2000

Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.

async Client.send_msg(self, data, event, msg_dict=None)

Source line 201

ParameterPassing conventionDefault / required
datapositional or keywordrequired
eventpositional or keywordrequired
msg_dictpositional or keywordNone

Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.

async Client.check(self, raw_msg)

Source line 234

ParameterPassing conventionDefault / required
raw_msgpositional or keywordrequired

Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.

async Client.ping(self, msg_size=0, trial_count=1, send_as_bytes=False, timeout=1000, interval=0.5)

Source line 300

ParameterPassing conventionDefault / required
msg_sizepositional or keyword0
trial_countpositional or keyword1
send_as_bytespositional or keywordFalse
timeoutpositional or keyword1000
intervalpositional or keyword0.5

Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.

class WSMessage

Source line 312

Construct: WSMessage()

Fields assigned by the constructor: construct_final, construct_id, construct_index, data, event, msg, msg_type, response_id, type. Some assignments may be conditional; see the object guide for meaning and lifecycle.

Declared functions, properties, and nested objects:

WSMessage.json_load(s)

Source line 313

ParameterPassing conventionDefault / required
spositional or keywordrequired
WSMessage.__init__(self)

Source line 315

No caller-supplied parameters are declared.

WSMessage.__repr__(self)

Source line 325

No caller-supplied parameters are declared.

WSMessage.construct_msg(msg_list)

Source line 327

ParameterPassing conventionDefault / required
msg_listpositional or keywordrequired
WSMessage.from_msg(msg)

Source line 346

ParameterPassing conventionDefault / required
msgpositional or keywordrequired
WSMessage.from_msg_dict(msg_dict)

Source line 374

ParameterPassing conventionDefault / required
msg_dictpositional or keywordrequired
async on_connect(client)

Source line 406

ParameterPassing conventionDefault / required
clientpositional or keywordrequired

Declared with async def. Normal calls return an awaitable, or an async generator if the body yields. Decorated properties may be accessed differently; follow the object guide.