Skip to content

Commit 21531c0

Browse files
committed
Add heartbeat recipe (#22)
Add a heartbeat recipe to the README. Also add a heartbeat mode to the example client.
1 parent 2bbae1b commit 21531c0

File tree

2 files changed

+80
-2
lines changed

2 files changed

+80
-2
lines changed

README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,67 @@ trio.run(main)
7878
A longer example is in `examples/server.py`. **See the note above about using
7979
SSL with the example client.**
8080

81+
## Heartbeat recipe
82+
83+
If you wish to keep a connection open for long periods of time but do not need
84+
to send messages frequently, then a heartbeat holds the connection open and also
85+
detects when the connection drops unexpectedly. The following recipe
86+
demonstrates how to implement a connection heartbeat using WebSocket's ping/pong
87+
feature.
88+
89+
```python
90+
async def heartbeat(ws, timeout, interval):
91+
'''
92+
Send periodic pings on WebSocket ``ws``. Wait up to ``timeout`` seconds to
93+
receive a pong before raising an exception. If a pong is received, then wait
94+
``interval`` seconds before sending the next ping.
95+
'''
96+
while True:
97+
with trio.fail_after(timeout):
98+
await ws.ping()
99+
await trio.sleep(interval)
100+
101+
async def main():
102+
async with open_websocket_url('ws://localhost/foo') as ws:
103+
async with trio.open_nursery() as nursery:
104+
nursery.start_soon(heartbeat, ws, 5, 1)
105+
# Your application code goes here:
106+
pass
107+
108+
trio.run(main)
109+
```
110+
111+
Note that the `ping()` method waits until it receives a pong frame, so it
112+
ensures that the remote endpoint is still responsive. If the connection is
113+
dropped unexpectedly or takes too long to respond, then `heartbeat()` will raise
114+
an exception that will cancel the nursery. You may wish to implement additional
115+
logic to automatically reconnect.
116+
117+
A heartbeat feature can be enabled in the example client with the
118+
``--heartbeat`` flag.
119+
120+
**Note that the WebSocket RFC does not require a WebSocket to send a pong for each
121+
ping:**
122+
123+
> If an endpoint receives a Ping frame and has not yet sent Pong frame(s) in
124+
> response to previous Ping frame(s), the endpoint MAY elect to send a Pong
125+
> frame for only the most recently processed Ping frame.
126+
127+
Therefore, if you have multiple pings in flight at the same time, you may not
128+
get an equal number of pongs in response. The simplest strategy for dealing with
129+
this is to only have one ping in flight at a time, as seen in the example above.
130+
As an alternative, you can send a `bytes` payload with each ping. The server
131+
will return the payload with the pong:
132+
133+
```python
134+
await ws.ping(b'my payload')
135+
pong == await ws.wait_pong()
136+
assert pong == b'my payload'
137+
```
138+
139+
You may want to embed a nonce or counter in the payload in order to correlate
140+
pong events to the pings you have sent.
141+
81142
## Unit Tests
82143

83144
Unit tests are written in the pytest style. You must install the development

examples/client.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ def commands():
3232
def parse_args():
3333
''' Parse command line arguments. '''
3434
parser = argparse.ArgumentParser(description='Example trio-websocket client')
35+
parser.add_argument('--heartbeat', action='store_true',
36+
help='Create a heartbeat task')
3537
parser.add_argument('url', help='WebSocket URL to connect to')
3638
return parser.parse_args()
3739

@@ -53,17 +55,19 @@ async def main(args):
5355
try:
5456
logging.debug('Connecting to WebSocket…')
5557
async with open_websocket_url(args.url, ssl_context) as conn:
56-
await handle_connection(conn)
58+
await handle_connection(conn, args.heartbeat)
5759
except OSError as ose:
5860
logging.error('Connection attempt failed: %s', ose)
5961
return False
6062

6163

62-
async def handle_connection(ws):
64+
async def handle_connection(ws, use_heartbeat):
6365
''' Handle the connection. '''
6466
logging.debug('Connected!')
6567
try:
6668
async with trio.open_nursery() as nursery:
69+
if use_heartbeat:
70+
nursery.start_soon(heartbeat, ws, 1, 15)
6771
nursery.start_soon(get_commands, ws)
6872
nursery.start_soon(get_messages, ws)
6973
except ConnectionClosed as cc:
@@ -72,6 +76,19 @@ async def handle_connection(ws):
7276
print('Closed: {}/{} {}'.format(cc.reason.code, cc.reason.name, reason))
7377

7478

79+
async def heartbeat(ws, timeout, interval):
80+
'''
81+
Send periodic pings on WebSocket ``ws``.
82+
After sending a ping, wait up to ``timeout`` seconds to receive a pong
83+
before raising an exception. If a pong is received, then wait ``interval``
84+
seconds before sending the next ping.
85+
'''
86+
while True:
87+
with trio.fail_after(timeout):
88+
await ws.ping()
89+
await trio.sleep(interval)
90+
91+
7592
async def get_commands(ws):
7693
''' In a loop: get a command from the user and execute it. '''
7794
while True:

0 commit comments

Comments
 (0)