Skip to content

Commit

Permalink
Add heartbeat recipe (#22)
Browse files Browse the repository at this point in the history
Add a heartbeat recipe to the README. Also add a heartbeat mode to
the example client.
  • Loading branch information
mehaase committed Oct 16, 2018
1 parent 2bbae1b commit 21531c0
Show file tree
Hide file tree
Showing 2 changed files with 80 additions and 2 deletions.
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,67 @@ 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:
with trio.fail_after(timeout):
await ws.ping()
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)
```

Note that the `ping()` method waits until it receives a pong frame, so it
ensures that the remote endpoint is still responsive. 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
Expand Down
21 changes: 19 additions & 2 deletions examples/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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:
Expand All @@ -72,6 +76,19 @@ 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:
with trio.fail_after(timeout):
await ws.ping()
await trio.sleep(interval)


async def get_commands(ws):
''' In a loop: get a command from the user and execute it. '''
while True:
Expand Down

0 comments on commit 21531c0

Please sign in to comment.