Skip to content

Commit a71aaac

Browse files
authored
Merge pull request #89 from HyperionGray/add_timeouts
Implement timeouts (#64)
2 parents 768ff39 + d2ce97b commit a71aaac

File tree

6 files changed

+459
-25
lines changed

6 files changed

+459
-25
lines changed

docs/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Autobahn Test Suite <https://github.com/crossbario/autobahn-testsuite>`__.
3232
getting_started
3333
clients
3434
servers
35+
timeouts
3536
api
3637
recipes
3738
contributing

docs/recipes.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ feature.
4242
await trio.sleep(interval)
4343
4444
async def main():
45-
async with open_websocket_url('ws://localhost/foo') as ws:
45+
async with open_websocket_url('ws://my.example/') as ws:
4646
async with trio.open_nursery() as nursery:
4747
nursery.start_soon(heartbeat, ws, 5, 1)
4848
# Your application code goes here:

docs/servers.rst

+2-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ As explained in the tutorial, a WebSocket server needs a handler function and a
4040
host/port to bind to. The handler function receives a
4141
:class:`WebSocketRequest` object, and it calls the request's
4242
:func:`~WebSocketRequest.accept` method to finish the handshake and obtain a
43-
:class:`WebSocketConnection` object.
43+
:class:`WebSocketConnection` object. When the handler function exits, the
44+
connection is automatically closed.
4445

4546
.. autofunction:: serve_websocket
4647

docs/timeouts.rst

+197
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
Timeouts
2+
========
3+
4+
.. currentmodule:: trio_websocket
5+
6+
Networking code is inherently complex due to the unpredictable nature of network
7+
failures and the possibility of a remote peer that is coded incorrectly—or even
8+
maliciously! Therefore, your code needs to deal with unexpected circumstances.
9+
One common failure mode that you should guard against is a slow or unresponsive
10+
peer.
11+
12+
This page describes the timeout behavior in ``trio-websocket`` and shows various
13+
examples for implementing timeouts in your own code. Before reading this, you
14+
might find it helpful to read `"Timeouts and cancellation for humans"
15+
<https://vorpus.org/blog/timeouts-and-cancellation-for-humans/>`__, an article
16+
written by Trio's author that describes an overall philosophy regarding
17+
timeouts. The short version is that Trio discourages libraries from using
18+
internal timeouts. Instead, it encourages the caller to enforce timeouts, which
19+
makes timeout code easier to compose and reason about.
20+
21+
On the other hand, this library is intended to be safe to use, and omitting
22+
timeouts could be a dangerous flaw. Therefore, this library takes a balanced
23+
approach to timeouts, where high-level APIs have internal timeouts, but you may
24+
disable them or use lower-level APIs if you want more control over the behavior.
25+
26+
Message Timeouts
27+
----------------
28+
29+
As a motivating example, let's write a client that sends one message and then
30+
expects to receive one message. To guard against a misbehaving server or
31+
network, we want to place a 15 second timeout on this combined send/receive
32+
operation. In other libraries, you might find that the APIs have ``timeout``
33+
arguments, but that style of timeout is very tedious when composing multiple
34+
operations. In Trio, we have helpful abstractions like cancel scopes, allowing
35+
us to implement our example like this:
36+
37+
.. code-block:: python
38+
39+
async with open_websocket_url('ws://my.example/') as ws:
40+
with trio.fail_after(15):
41+
await ws.send_message('test')
42+
msg = await ws.get_message()
43+
print('Received message: {}'.format(msg))
44+
45+
The 15 second timeout covers the cumulative time to send one message and to wait
46+
for one response. It raises ``TooSlowError`` if the runtime exceeds 15 seconds.
47+
48+
Connection Timeouts
49+
-------------------
50+
51+
The example in the previous section ignores one obvious problem: what if
52+
connecting to the server or closing the connection takes a long time? How do we
53+
apply a timeout to those operations? One option is to put the entire connection
54+
inside a cancel scope:
55+
56+
.. code-block:: python
57+
58+
with trio.fail_after(15):
59+
async with open_websocket_url('ws://my.example/') as ws:
60+
await ws.send_message('test')
61+
msg = await ws.get_message()
62+
print('Received message: {}'.format(msg))
63+
64+
The approach suffices if we want to compose all four operations into one
65+
timeout: connect, send message, get message, and disconnect. But this approach
66+
will not work if want to separate the timeouts for connecting/disconnecting from
67+
the timeouts for sending and receiving. Let's write a new client that sends
68+
messages periodically, waiting up to 15 seconds for a response to each message
69+
before sending the next message.
70+
71+
.. code-block:: python
72+
73+
async with open_websocket_url('ws://my.example/') as ws:
74+
for _ in range(10):
75+
await trio.sleep(30)
76+
with trio.fail_after(15):
77+
await ws.send_message('test')
78+
msg = await ws.get_message()
79+
print('Received message: {}'.format(msg))
80+
81+
In this scenario, the ``for`` loop will take at least 300 seconds to run, so we
82+
would like to specify timeouts that apply to connecting and disconnecting but do
83+
not apply to the contents of the context manager block. This is tricky because
84+
the connecting and disconnecting are handled automatically inside the context
85+
manager :func:`open_websocket_url`. Here's one possible approach:
86+
87+
.. code-block:: python
88+
89+
with trio.fail_after(10) as cancel_scope:
90+
async with open_websocket_url('ws://my.example'):
91+
cancel_scope.deadline = math.inf
92+
for _ in range(10):
93+
await trio.sleep(30)
94+
with trio.fail_after(15):
95+
await ws.send_message('test')
96+
msg = await ws.get_message()
97+
print('Received message: {}'.format(msg))
98+
cancel_scope.deadline = trio.current_time() + 5
99+
100+
This example places a 10 second timeout on connecting and a separate 5 second
101+
timeout on disconnecting. This is accomplished by wrapping the entire operation
102+
in a cancel scope and then modifying the cancel scope's deadline when entering
103+
and exiting the context manager block.
104+
105+
This approach works but it is a bit complicated, and we don't want our safety
106+
mechanisms to be complicated! Therefore, the high-level client APIs
107+
:func:`open_websocket` and :func:`open_websocket_url` contain internal timeouts
108+
that apply only to connecting and disconnecting. Let's rewrite the previous
109+
example to use the library's internal timeouts:
110+
111+
.. code-block:: python
112+
113+
async with open_websocket_url('ws://my.example/', connect_timeout=10,
114+
disconnect_timeout=5) as ws:
115+
for _ in range(10):
116+
await trio.sleep(30)
117+
with trio.fail_after(15):
118+
await ws.send_message('test')
119+
msg = await ws.get_message()
120+
print('Received message: {}'.format(msg))
121+
122+
Just like the previous example, this puts a 10 second timeout on connecting, a
123+
separate 5 second timeout on disconnecting. These internal timeouts violate the
124+
Trio philosophy of composable timeouts, but hopefully the examples in this
125+
section have convinced you that breaking the rules a bit is justified by the
126+
improved safety and ergonomics of this version.
127+
128+
In fact, these timeouts have actually been present in all of our examples so
129+
far! We just didn't see them because those arguments have default values. If you
130+
really don't like the internal timeouts, you can disable them by passing
131+
``math.inf``, or you can use the low-level APIs instead.
132+
133+
Timeouts on Low-level APIs
134+
--------------------------
135+
136+
In the previous section, we saw how the library's high-level APIs have internal
137+
timeouts. The low-level APIs, like :func:`connect_websocket` and
138+
:func:`connect_websocket_url` do not have internal timeouts, nor are they
139+
context managers. These characteristics make the low-level APIs suitable for
140+
situations where you want very fine-grained control over timeout behavior.
141+
142+
.. code-block:: python
143+
144+
async with trio.open_nursery():
145+
with trio.fail_after(10):
146+
connection = await connect_websocket_url(nursery, 'ws://my.example/')
147+
try:
148+
for _ in range(10):
149+
await trio.sleep(30)
150+
with trio.fail_after(15):
151+
await ws.send_message('test')
152+
msg = await ws.get_message()
153+
print('Received message: {}'.format(msg))
154+
finally:
155+
with trio.fail_after(5):
156+
await connection.aclose()
157+
158+
This example applies the same 10 second timeout for connecting and 5 second
159+
timeout for disconnecting as seen in the previous section, but it uses the
160+
lower-level APIs. This approach gives you more control but the low-level APIs
161+
also require more boilerplate, such as creating a nursery and using try/finally
162+
to ensure that the connection is always closed.
163+
164+
Server Timeouts
165+
---------------
166+
167+
The server API also has internal timeouts. These timeouts are configured when
168+
the server is created, and they are enforced on each connection.
169+
170+
.. code-block:: python
171+
172+
async def handler(request):
173+
ws = await request.accept()
174+
msg = await ws.get_message()
175+
print('Received message: {}'.format(msg))
176+
177+
await serve_websocket(handler, 'localhost', 8080, ssl_context=None,
178+
connect_timeout=10, disconnect_timeout=5)
179+
180+
The server timeouts work slightly differently from the client timeouts. The
181+
server's connect timeout measures the time between receiving a new TCP
182+
connection and calling the user's handler. The connect timeout
183+
includes waiting for the client's side of the handshake (which is represented by
184+
the ``request`` object), *but it does not include the server's side of the
185+
handshake.* The server handshake needs to be performed inside the user's
186+
handler, e.g. ``await request.accept()``. The disconnect timeout applies to the
187+
time between the handler exiting and the connection being closed.
188+
189+
Each handler is spawned inside of a nursery, so there is no way for connect and
190+
disconnect timeouts to raise exceptions to your code. (If they did raise
191+
exceptions, they would cancel your nursery and crash your server!) Instead,
192+
connect timeouts cause the connection to be silently closed, and the handler is
193+
never called. For disconnect timeouts, your handler has already exited, so a
194+
timeout will cause the connection to be silently closed.
195+
196+
As with the client APIs, you can disable the internal timeouts by passing
197+
``math.inf`` or you can use low-level APIs like :func:`wrap_server_stream`.

0 commit comments

Comments
 (0)