diff --git a/README.md b/README.md index 005d18c..60c9d4a 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,66 @@ trio.run(main) A longer example is in `examples/server.py`. **See the note above about using SSL with the example client.** +## Heartbeat recipe + +If you wish to keep a connection open for long periods of time but do not need +to send messages frequently, then a heartbeat holds the connection open and also +detects when the connection drops unexpectedly. The following recipe +demonstrates how to implement a connection heartbeat using WebSocket's ping/pong +feature. + +```python +async def heartbeat(ws, timeout, interval): + ''' + Send periodic pings on WebSocket ``ws``. Wait up to ``timeout`` seconds to + receive a pong before raising an exception. If a pong is received, then wait + ``interval`` seconds before sending the next ping. + ''' + while True: + await ws.ping() + with trio.fail_after(timeout): + await ws.wait_pong() + await trio.sleep(interval) + +async def main(): + async with open_websocket_url('ws://localhost/foo') as ws: + async with trio.open_nursery() as nursery: + nursery.start_soon(heartbeat, ws, 5, 1) + # Your application code goes here: + pass + +trio.run(main) +``` + +If the connection is dropped unexpectedly or takes too long to respond, then +`heartbeat()` will raise an exception that will cancel the nursery. You may wish +to implement additional logic to automatically reconnect. + +A heartbeat feature can be enabled in the example client with the +``--heartbeat`` flag. + +**Note that the WebSocket RFC does not require a WebSocket to send a pong for each +ping:** + +> If an endpoint receives a Ping frame and has not yet sent Pong frame(s) in +> response to previous Ping frame(s), the endpoint MAY elect to send a Pong +> frame for only the most recently processed Ping frame. + +Therefore, if you have multiple pings in flight at the same time, you may not +get an equal number of pongs in response. The simplest strategy for dealing with +this is to only have one ping in flight at a time, as seen in the example above. +As an alternative, you can send a `bytes` payload with each ping. The server +will return the payload with the pong: + +```python +await ws.ping(b'my payload') +pong == await ws.wait_pong() +assert pong == b'my payload' +``` + +You may want to embed a nonce or counter in the payload in order to correlate +pong events to the pings you have sent. + ## Unit Tests Unit tests are written in the pytest style. You must install the development diff --git a/examples/client.py b/examples/client.py index a9a8ff5..ccd638e 100644 --- a/examples/client.py +++ b/examples/client.py @@ -32,6 +32,8 @@ def commands(): def parse_args(): ''' Parse command line arguments. ''' parser = argparse.ArgumentParser(description='Example trio-websocket client') + parser.add_argument('--heartbeat', action='store_true', + help='Create a heartbeat task') parser.add_argument('url', help='WebSocket URL to connect to') return parser.parse_args() @@ -53,17 +55,19 @@ async def main(args): try: logging.debug('Connecting to WebSocket…') async with open_websocket_url(args.url, ssl_context) as conn: - await handle_connection(conn) + await handle_connection(conn, args.heartbeat) except OSError as ose: logging.error('Connection attempt failed: %s', ose) return False -async def handle_connection(ws): +async def handle_connection(ws, use_heartbeat): ''' Handle the connection. ''' logging.debug('Connected!') try: async with trio.open_nursery() as nursery: + if use_heartbeat: + nursery.start_soon(heartbeat, ws, 1, 15) nursery.start_soon(get_commands, ws) nursery.start_soon(get_messages, ws) except ConnectionClosed as cc: @@ -72,6 +76,21 @@ async def handle_connection(ws): print('Closed: {}/{} {}'.format(cc.reason.code, cc.reason.name, reason)) +async def heartbeat(ws, timeout, interval): + ''' + Send periodic pings on WebSocket ``ws``. + + After sending a ping, wait up to ``timeout`` seconds to receive a pong + before raising an exception. If a pong is received, then wait ``interval`` + seconds before sending the next ping. + ''' + while True: + await ws.ping(b'heartbeat') + with trio.fail_after(timeout): + await ws.wait_pong() + await trio.sleep(interval) + + async def get_commands(ws): ''' In a loop: get a command from the user and execute it. ''' while True: