Python client for Rails' Action Cable. asyncio, no dependencies, fully typed.
pip install actioncable-clientimport asyncio
import actioncable
# Establish a connection
client = actioncable.Client("wss://example.com/cable")
await client.connect(timeout=10)
# Subscribe to a channel
room = await client.subscribe(actioncable.Identifier("RoomChannel", {"id": 42}))
# Listen for incoming messages
async def listen():
async for message in room:
print(message.json()["body"])
listening = asyncio.create_task(listen())
# Send messages
await room.perform("speak", {"body": "Hello!"})connect opens a connection and waits for the server to acknowledge. Failed
attempts get retried automatically. Pass a timeout to control how long to
wait:
try:
await client.connect(timeout=10)
except actioncable.DisconnectError as disconnect:
if disconnect.reason == actioncable.REASON_UNAUTHORIZED:
await sign_in_again()
else:
raiseThe errors worth handling:
TimeoutError, whentimeoutruns out before the welcome arrives, andasyncio.CancelledErrorwhen the task awaitingconnectis cancelled. The timeout also names whatever the last attempt failed on, both in its message and as its__cause__, so a header that couldn't be built or a refused dial shows up in it.DisconnectError, when the server sends a disconnect message.reasonis one of four strings:REASON_UNAUTHORIZED— authentication or authorization failed.REASON_INVALID_REQUEST— the request wasn't a valid Action Cable upgrade.REASON_SERVER_RESTART— the Rails server is restarting.REASON_REMOTE— the app closed this connection withActionCable.server.disconnect.
UnsupportedSubprotocolError, when the server picks a protocol this client doesn't speak.GaveUpError, whenmax_attemptsis set and that many attempts failed in a row. Its__cause__is the last attempt's error.
Everything above descends from ActionCableError, so one except catches the
lot.
A disconnect message says whether the client should re-connect. Only the ones that say no raise. The rest are retried, so a server restart shows up in the log and the connection returns on its own.
A connect that raises leaves the client stopped, with nothing running behind
it. Throw it away and make a new one. The one exception is
AlreadyConnectedError, which means connect was already called on a client
that is running fine.
A client that stopped later on — the server hung up for good, or it ran out of
attempts — says so through done and error:
await client.done()
print("cable stopped:", client.error)error is None while the client runs, and afterwards one of the errors above
or ClosedError. A stopped client doesn't come back; make a new one.
subscribe sends the subscription and waits for the channel to confirm it. It
raises RejectedError when the channel's subscribed method rejects it.
Subscribing twice to the same identifier shares one subscription on the server.
Rails keeps one per identifier per connection and ignores a second subscribe,
so the client sends one, hands every message to each Subscription, and tells
the server to unsubscribe when the last one does. A subscribe that finds the
identifier already confirmed returns right away; one that finds a subscribe
still in flight waits for its verdict.
The message iterator ends when the subscription is unsubscribed, rejected, or
the client stops, so an async for over it finishes on its own. error on the
subscription says which it was: UnsubscribedError, RejectedError, or
whatever stopped the client.
async for message in room:
await handle(message)
if not isinstance(room.error, actioncable.UnsubscribedError):
print("subscription ended:", room.error)room.messages() is the same iterator, for code that would rather name it.
Read it promptly. A subscription buffers 64 messages, and a message that arrives while the buffer is full gets dropped and logged rather than stalling the connection. Set a bigger buffer if the reader can't keep up with a burst:
client = actioncable.Client("wss://example.com/cable", message_buffer=1000)The buffer size applies to every subscription on the client.
A Message is the JSON text the channel sent — it is a str — and json()
decodes it.
Action Cable servers can authorize connections using cookies or headers.
Use cookie to set a cookie when establishing a connection:
client = actioncable.Client("wss://example.com/cable", cookie="_session_id=...")headers sets any other header:
client = actioncable.Client("wss://example.com/cable", headers={"X-Api-Token": "..."})A credential that expires should use headers_func, which runs on every
reconnect and may be a coroutine function:
async def authorization():
return {"Authorization": f"Bearer {await credentials.access_token()}"}
client = actioncable.Client("wss://example.com/cable", headers_func=authorization)What it returns is merged over the headers already set, so an Origin or an
API token given with headers is kept. An error turns down that dial, and the
client tries again on its backoff.
When an application can identify an error that another connection attempt
cannot repair, use stop_on_error. The client stops with the original error,
while other connection failures continue to retry:
client = actioncable.Client(
"wss://example.com/cable",
headers_func=authorization,
stop_on_error=lambda error: isinstance(error, SignedOut),
)Rails also checks the Origin header and rejects a request that doesn't carry
one. By default, the Origin is set to the server's URL, so
wss://example.com/cable sends https://example.com.
Set it explicitly when the server sees a different scheme or host than the URL says, behind a proxy that terminates TLS for instance:
client = actioncable.Client("wss://example.com/cable", origin="http://example.com")Some channels only send what's new, so a reconnect can leave a gap. Only the
client knows a reconnect happened, so subscribe takes callbacks for the
connection events:
async def connected(reconnected):
if reconnected:
await catch_up()
room = await client.subscribe(
identifier,
on_connected=connected,
on_disconnected=lambda will_reconnect: ...,
on_rejected=lambda: ...,
)on_connected runs every time the server confirms the subscription.
reconnected is false the first time and true every time after.
on_disconnected runs when the connection drops. will_reconnect says whether
the client is coming back or has stopped for good.
on_rejected runs when the channel rejects the subscription.
Each callback may be a plain function or a coroutine function. They run on
their own task, one at a time, in order, so close, subscribe and
unsubscribe all work from inside one. The message iterator ends only after
the last callback has returned, so once an async for over it finishes, no
callback is still running or about to.
unsubscribe takes no deadline. The command goes out on the client's own
connection, so it works during a teardown whose timeout has already run out.
Rails sends a ping every three seconds and the client watches for it. After six seconds of silence the client treats the connection as dead, drops it, and dials again after a second, then two, then four, up to thirty. Each delay carries a little jitter, so a restarted server doesn't get every client back at once.
Both are configurable, and the retrying can be capped:
client = actioncable.Client(
"wss://example.com/cable",
stale_after=10,
backoff=(1, 30),
max_attempts=10,
)Durations are seconds, as everywhere else in asyncio.
By default the client keeps dialing until close. With max_attempts it stops
with GaveUpError after that many failures in a row; a welcome resets the
count, so it bounds one outage rather than the client's lifetime.
Subscriptions come back on their own. The client resubscribes all of them on
the new connection, then resends a subscribe every half second until the server
confirms it, because a subscribe that arrives before the connection is set up
gets dropped. The same Subscription and the same message iterator keep
working throughout.
Actions don't come back. perform and send raise NotConnectedError while
the connection is down, or up but not yet welcomed, since Rails discards
anything that arrives that early. Send it again if it matters.
The client speaks over its own RFC 6455 implementation by default, written on
asyncio streams and the ssl module.
An application that already uses a WebSocket library can keep using it by implementing two protocols.
Transport has one method:
class Transport(Protocol):
async def dial(self, url: str, options: DialOptions) -> Conn: ...dial opens one connection. options carries the subprotocols the client's
protocols negotiate under, and the headers that authorize the request.
Conn has four:
class Conn(Protocol):
@property
def subprotocol(self) -> str: ...
async def read(self) -> bytes: ...
async def write(self, payload: bytes) -> None: ...
async def close(self) -> None: ...subprotocol returns the subprotocol the server picked, empty if it picked
none. read returns the next complete message. write sends one text message.
close hangs up, and has to interrupt a read or write running at the time.
Neither read nor write takes a deadline: the client bounds them by
cancelling the task, which is asyncio's way of saying what a context does in
Go.
Implement both and pass the transport to the client:
import websockets
class WebsocketsTransport:
async def dial(self, url, options):
socket = await websockets.connect(
url,
subprotocols=options.subprotocols,
additional_headers=dict(options.headers),
)
return WebsocketsConn(socket)
class WebsocketsConn:
def __init__(self, socket):
self._socket = socket
@property
def subprotocol(self):
return self._socket.subprotocol or ""
async def read(self):
return await self._socket.recv()
async def write(self, payload):
await self._socket.send(payload)
async def close(self):
await self._socket.close()
client = actioncable.Client(url, transport=WebsocketsTransport())The default is WebSocketTransport, which speaks RFC 6455 on the standard
library. Three of its failures are typed, for a caller that wants to act on
them rather than read them:
HandshakeError, when the server answers the upgrade with anything but 101.status_codetells a redirect from a refusal.CloseError, fromreadwhen the server sends a close frame, with itscodeandreason.MessageTooBigError, fromreadwhen a message is larger thanmax_message_size. It is refused as soon as its length is known, before any of it is read in.
Its connections also implement StatusCloser: close_with_status hangs up
with a code and reason of the caller's choosing where close sends 1000.
Action Cable servers can talk multiple protocols. Rails' default is V1-JSON and that's what's supported out of the box. But, if needed, new protocols can be added.
The Protocol protocol has just three members:
class Protocol(Protocol):
@property
def subprotocol(self) -> str: ...
def encode(self, command: Command) -> bytes: ...
def decode(self, payload: bytes) -> Incoming: ...subprotocol returns the WebSocket subprotocol for the protocol. encode
serializes a command to the protocol's wire format, while decode does the
opposite.
All protocols will be offered to the server in that order. If one protocol is preferred over another then it should be listed first:
client = actioncable.Client(url, protocols=[V2MessagePack(), actioncable.V1JSON()])additional_protocols is shorthand for adding new protocols to the default
list. They are prepended, which means they'll be preferred:
client = actioncable.Client(url, additional_protocols=[V2MessagePack()])The default is V1JSON, which speaks actioncable-v1-json, Rails' default
protocol.
actioncable.testing ships the in-memory transport this client's own tests are
written with, so an application's tests can play the server without a socket:
from actioncable.testing import FakeTransport
transport = FakeTransport()
client = actioncable.Client("ws://cable.example.com/cable", transport=transport)
connecting = asyncio.create_task(client.connect())
conn = await transport.accept()
await conn.welcome()
await connecting
subscribing = asyncio.create_task(client.subscribe(actioncable.Identifier("RoomChannel")))
await conn.expect_command(actioncable.CommandName.SUBSCRIBE, '{"channel":"RoomChannel"}')
await conn.confirm('{"channel":"RoomChannel"}')
room = await subscribing
await conn.push('{"identifier":"{\\"channel\\":\\"RoomChannel\\"}","message":{"body":"Hello!"}}')
assert (await anext(aiter(room))).json() == {"body": "Hello!"}The two behave alike; these are the places the language made the decision:
- Cancellation is asyncio's. Go passes a
context.ContextintoConnect,Subscribe,PerformandSend. Here the caller cancels the task, or passestimeout=toconnectandsubscribe— which is also how the timeout can still name the failure it was waiting out, something anasyncio.timeout()wrapped around the call could not. - Errors are classes, not values.
except RejectedErrordoes whaterrors.Is(err, ErrRejected)does, andraise ... from ...builds the chain%wbuilds. Messageis the JSON text, re-encoded. Go keeps the exact bytes the server sent; Python's JSON decoder does not hand them back, so the text is encoded again from the decoded value. The value is the same, and only insignificant whitespace differs.- There are no locks. Everything runs on one event loop, so the state Go guards with a mutex only changes between awaits. The one lock left is the one that keeps commands in order on the wire.
- The logger is
logging. Passlogger=alogging.Logger; the default is theactioncablelogger with a null handler, so nothing is emitted until an application configures logging.
Read CONTRIBUTING.md first. Discussions come before issues and pull requests.
Released under the MIT License. See LICENSE.