Skip to content

Commit 72a2e70

Browse files
committed
Add support for reconnecting automatically.
Fix #414.
1 parent ebc8448 commit 72a2e70

File tree

5 files changed

+81
-9
lines changed

5 files changed

+81
-9
lines changed

docs/howto/logging.rst

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,9 @@ Disable logging
170170

171171
If your application doesn't configure :mod:`logging`, Python outputs messages
172172
of severity :data:`~logging.WARNING` and higher to :data:`~sys.stderr`. As a
173-
consequence, you will see a message and a stack trace if a connection handler
174-
coroutine crashes or if you hit a bug in websockets.
173+
consequence, you will see a message and a stack trace if a server crashes in
174+
a connection handler coroutine, if a client crashes and relies on automatic
175+
reconnection, or if you hit a bug in websockets.
175176

176177
If you want to disable this behavior for websockets, you can add
177178
a :class:`~logging.NullHandler`::
@@ -203,8 +204,10 @@ Here's what websockets logs at each level.
203204
......................
204205

205206
* Exceptions raised by connection handler coroutines in servers
207+
* Exceptions swallowed by automatic reconnection in clients
206208
* Exceptions resulting from bugs in websockets
207209

210+
208211
:attr:`~logging.INFO`
209212
.....................
210213

docs/project/changelog.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ They may change at any time.
5151

5252
* Added compatibility with Python 3.10.
5353

54+
* Added support for reconnecting automatically by using
55+
:func:`~legacy.client.connect` as an asynchronous iterator.
56+
5457
* Added ``open_timeout`` to :func:`~legacy.client.connect`.
5558

5659
* Improved logging.

docs/spelling_wordlist.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ autoscaler
66
awaitable
77
aymeric
88
backend
9+
backoff
910
backpressure
1011
balancer
1112
balancers
@@ -52,6 +53,7 @@ pong
5253
pongs
5354
proxying
5455
pythonic
56+
reconnection
5557
redis
5658
retransmit
5759
runtime

src/websockets/legacy/client.py

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,22 @@
99
import logging
1010
import warnings
1111
from types import TracebackType
12-
from typing import Any, Callable, Generator, List, Optional, Sequence, Tuple, Type, cast
12+
from typing import (
13+
Any,
14+
AsyncIterator,
15+
Callable,
16+
Generator,
17+
List,
18+
Optional,
19+
Sequence,
20+
Tuple,
21+
Type,
22+
cast,
23+
)
1324

1425
from ..datastructures import Headers, HeadersLike
1526
from ..exceptions import (
27+
ConnectionClosed,
1628
InvalidHandshake,
1729
InvalidHeader,
1830
InvalidMessage,
@@ -413,12 +425,22 @@ class Connect:
413425
Awaiting :func:`connect` yields a :class:`WebSocketClientProtocol` which
414426
can then be used to send and receive messages.
415427
416-
:func:`connect` can also be used as a asynchronous context manager::
428+
:func:`connect` can be used as a asynchronous context manager::
417429
418430
async with connect(...) as websocket:
419431
...
420432
421-
In that case, the connection is closed when exiting the context.
433+
The connection is closed automatically when exiting the context.
434+
435+
:func:`connect` can be used as an infinite asynchronous iterator to
436+
reconnect automatically on errors::
437+
438+
async for websocket in connect(...):
439+
...
440+
441+
As above, connections are closed automatically. Connection attempts are
442+
delayed with exponential backoff, starting at two seconds and increasing
443+
up to one minute.
422444
423445
:func:`connect` is a wrapper around the event loop's
424446
:meth:`~asyncio.loop.create_connection` method. Unknown keyword arguments
@@ -577,6 +599,10 @@ def __init__(
577599
)
578600

579601
self.open_timeout = open_timeout
602+
if logger is None:
603+
logger = logging.getLogger("websockets.client")
604+
self.logger = logger
605+
580606
# This is a coroutine function.
581607
self._create_connection = create_connection
582608
self._wsuri = wsuri
@@ -615,7 +641,45 @@ def handle_redirect(self, uri: str) -> None:
615641
# Set the new WebSocket URI. This suffices for same-origin redirects.
616642
self._wsuri = new_wsuri
617643

618-
# async with connect(...)
644+
BACKOFF_MIN = 2
645+
BACKOFF_MAX = 60
646+
BACKOFF_FACTOR = 1.5
647+
648+
# async for ... in connect(...):
649+
650+
async def __aiter__(self) -> AsyncIterator[WebSocketClientProtocol]:
651+
backoff_delay = self.BACKOFF_MIN
652+
while True:
653+
try:
654+
protocol = await self
655+
except Exception:
656+
# Connection timed out - increase backoff delay
657+
backoff_delay = min(int(1.5 * backoff_delay), self.BACKOFF_MAX)
658+
self.logger.error(
659+
"! connect failed; retrying in %d seconds", backoff_delay
660+
)
661+
await asyncio.sleep(backoff_delay)
662+
continue
663+
else:
664+
# Connection succeeded - reset backoff delay
665+
backoff_delay = self.BACKOFF_MIN
666+
667+
try:
668+
yield protocol
669+
except GeneratorExit:
670+
raise
671+
except ConnectionClosed:
672+
self.logger.debug("! connection closed; reconnecting", exc_info=True)
673+
# Remove this branch when dropping support for Python < 3.8
674+
# because CancelledError no longer inherits Exception.
675+
except asyncio.CancelledError:
676+
raise
677+
except Exception:
678+
self.logger.error("! an error occurred; reconnecting", exc_info=True)
679+
finally:
680+
await protocol.close()
681+
682+
# async with connect(...) as ...:
619683

620684
async def __aenter__(self) -> WebSocketClientProtocol:
621685
return await self
@@ -628,7 +692,7 @@ async def __aexit__(
628692
) -> None:
629693
await self.protocol.close()
630694

631-
# await connect(...)
695+
# ... = await connect(...)
632696

633697
def __await__(self) -> Generator[Any, None, WebSocketClientProtocol]:
634698
# Create a suitable iterator by calling __await__ on a coroutine.
@@ -665,7 +729,7 @@ async def __await_impl__(self) -> WebSocketClientProtocol:
665729
else:
666730
raise SecurityError("too many redirects")
667731

668-
# yield from connect(...)
732+
# ... = yield from connect(...)
669733

670734
__iter__ = __await__
671735

src/websockets/legacy/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -904,7 +904,7 @@ class Serve:
904904
:exc:`~websockets.exceptions.ConnectionClosedOK` exception on their
905905
current or next interaction with the WebSocket connection.
906906
907-
:func:`serve` can also be used as an asynchronous context manager::
907+
:func:`serve` can be used as an asynchronous context manager::
908908
909909
stop = asyncio.Future() # set this future to exit the server
910910

0 commit comments

Comments
 (0)