Skip to content

Commit e211fab

Browse files
committed
Move process_exception to the Sans-I/O layer.
Even though it has to do with I/O, it's mutualized between all implementations, and it doesn't matter very much where it lives.
1 parent 59533d5 commit e211fab

10 files changed

Lines changed: 86 additions & 60 deletions

File tree

docs/howto/upgrade.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ network errors and server errors (HTTP 500, 502, 503, or 504) are considered
161161
retryable. You can customize this behavior with the ``process_exception``
162162
argument of :func:`~asyncio.client.connect`.
163163

164-
See :func:`~asyncio.client.process_exception` for more information.
164+
See :func:`~client.process_exception` for more information.
165165

166166
Here's how to revert to the behavior of the original implementation::
167167

docs/reference/asyncio/client.rst

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ Opening a connection
1010

1111
.. autofunction:: unix_connect
1212

13-
.. autofunction:: process_exception
14-
1513
Using a connection
1614
------------------
1715

docs/reference/sansio/client.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,5 @@ Client (`Sans-I/O`_)
5656
.. autoproperty:: close_reason
5757

5858
.. autoproperty:: close_exc
59+
60+
.. autofunction:: process_exception

docs/reference/sync/client.rst

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ Opening a connection
1414

1515
.. autofunction:: unix_reconnect
1616

17-
.. autofunction:: process_exception
18-
1917
Using a connection
2018
------------------
2119

docs/reference/trio/client.rst

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ Opening a connection
1616

1717
.. autofunction:: unix_connect
1818

19-
.. autofunction:: process_exception
20-
2119
Using a connection
2220
------------------
2321

src/websockets/asyncio/client.py

Lines changed: 3 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,9 @@
1111
from types import TracebackType
1212
from typing import Any, Callable, Literal, cast
1313

14-
from ..client import ClientProtocol, backoff
14+
from ..client import ClientProtocol, backoff, process_exception
1515
from ..datastructures import Headers, HeadersLike
1616
from ..exceptions import (
17-
InvalidMessage,
1817
InvalidProxyMessage,
1918
InvalidProxyStatus,
2019
InvalidStatus,
@@ -128,50 +127,6 @@ def process_event(self, event: Event) -> None:
128127
super().process_event(event)
129128

130129

131-
def process_exception(exc: Exception) -> Exception | None:
132-
"""
133-
Determine whether a connection error is retryable or fatal.
134-
135-
When reconnecting automatically with ``async for ... in connect(...)``, if a
136-
connection attempt fails, :func:`process_exception` is called to determine
137-
whether to retry connecting or to raise the exception.
138-
139-
This function defines the default behavior, which is to retry on:
140-
141-
* :exc:`EOFError`, :exc:`OSError`, :exc:`asyncio.TimeoutError`: network
142-
errors;
143-
* :exc:`~websockets.exceptions.InvalidStatus` when the status code is 500,
144-
502, 503, or 504: server or proxy errors.
145-
146-
All other exceptions are considered fatal.
147-
148-
You can change this behavior with the ``process_exception`` argument of
149-
:func:`connect`.
150-
151-
Return :obj:`None` if the exception is retryable i.e. when the error could
152-
be transient and trying to reconnect with the same parameters could succeed.
153-
The exception will be logged at the ``INFO`` level.
154-
155-
Return an exception, either ``exc`` or a new exception, if the exception is
156-
fatal i.e. when trying to reconnect will most likely produce the same error.
157-
That exception will be raised, breaking out of the retry loop.
158-
159-
"""
160-
# This catches python-socks' ProxyConnectionError and ProxyTimeoutError.
161-
if isinstance(exc, (OSError, TimeoutError)):
162-
return None
163-
if isinstance(exc, InvalidMessage) and isinstance(exc.__cause__, EOFError):
164-
return None
165-
if isinstance(exc, InvalidStatus) and exc.response.status_code in [
166-
500, # Internal Server Error
167-
502, # Bad Gateway
168-
503, # Service Unavailable
169-
504, # Gateway Timeout
170-
]:
171-
return None
172-
return exc
173-
174-
175130
# This is spelled in lower case because it's exposed as a callable in the API.
176131
class connect:
177132
"""
@@ -224,7 +179,8 @@ class connect:
224179
<../../topics/proxies>` for details.
225180
process_exception: When reconnecting automatically, tell whether an
226181
error is transient or fatal. The default behavior is defined by
227-
:func:`process_exception`. Refer to its documentation for details.
182+
:func:`~websockets.client.process_exception`. Refer to its
183+
documentation for details.
228184
open_timeout: Timeout for opening the connection in seconds.
229185
:obj:`None` disables the timeout.
230186
ping_interval: Interval between keepalive pings in seconds.

src/websockets/client.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,55 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
357357
super().__init__(*args, **kwargs)
358358

359359

360+
def process_exception(exc: Exception) -> Exception | None:
361+
"""
362+
Determine whether a connection error is retryable or fatal.
363+
364+
When reconnecting automatically with ``async for ... in connect(...)``
365+
(:mod:`asyncio`, :mod:`trio`) or ``for ... in reconnect(...)``
366+
(:mod:`threading`), whenever a connection attempt fails,
367+
:func:`process_exception` determines whether to retry connecting or to raise
368+
the exception.
369+
370+
This function defines the default behavior, which is to retry on:
371+
372+
* :exc:`OSError` and :exc:`asyncio.TimeoutError`: network errors;
373+
* :exc:`~websockets.exceptions.InvalidMessage` when it stems from an
374+
:exc:`EOFError`: also network errors;
375+
* :exc:`~websockets.exceptions.InvalidStatus` when the status code is 500,
376+
502, 503, or 504: server or proxy errors.
377+
378+
All other exceptions are considered fatal.
379+
380+
You can change this behavior with the ``process_exception`` argument of
381+
:func:`~websockets.asyncio.client.connect` (:mod:`asyncio`),
382+
:func:`~websockets.trio.client.connect` (:mod:`trio`), or
383+
:func:`~websockets.sync.client.reconnect` (:mod:`threading`).
384+
385+
Return :obj:`None` if the exception is retryable i.e. when the error could
386+
be transient and trying to reconnect with the same parameters could succeed.
387+
The exception will be logged at the ``INFO`` level.
388+
389+
Return an exception, either ``exc`` or a new exception, if the exception is
390+
fatal i.e. when trying to reconnect will most likely produce the same error.
391+
That exception will be raised, breaking out of the retry loop.
392+
393+
"""
394+
# This catches python-socks' ProxyConnectionError and ProxyTimeoutError.
395+
if isinstance(exc, (OSError, TimeoutError)):
396+
return None
397+
if isinstance(exc, InvalidMessage) and isinstance(exc.__cause__, EOFError):
398+
return None
399+
if isinstance(exc, InvalidStatus) and exc.response.status_code in [
400+
500, # Internal Server Error
401+
502, # Bad Gateway
402+
503, # Service Unavailable
403+
504, # Gateway Timeout
404+
]:
405+
return None
406+
return exc
407+
408+
360409
BACKOFF_INITIAL_DELAY = float(os.environ.get("WEBSOCKETS_BACKOFF_INITIAL_DELAY", "5"))
361410
BACKOFF_MIN_DELAY = float(os.environ.get("WEBSOCKETS_BACKOFF_MIN_DELAY", "3.1"))
362411
BACKOFF_MAX_DELAY = float(os.environ.get("WEBSOCKETS_BACKOFF_MAX_DELAY", "90.0"))

src/websockets/sync/client.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@
1313
from types import TracebackType
1414
from typing import Any, Callable, Literal, TypeVar, cast, overload
1515

16-
from ..asyncio.client import process_exception
17-
from ..client import ClientProtocol, backoff
16+
from ..client import ClientProtocol, backoff, process_exception
1817
from ..datastructures import Headers, HeadersLike
1918
from ..exceptions import (
2019
InvalidProxyMessage,

src/websockets/trio/client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@
1111

1212
import trio
1313

14-
from ..asyncio.client import process_exception
15-
from ..client import ClientProtocol, backoff
14+
from ..client import ClientProtocol, backoff, process_exception
1615
from ..datastructures import Headers, HeadersLike
1716
from ..exceptions import (
1817
InvalidProxyMessage,
@@ -195,7 +194,8 @@ class connect:
195194
``proxy_server_hostname`` overrides the host name from ``proxy``.
196195
process_exception: When reconnecting automatically, tell whether an
197196
error is transient or fatal. The default behavior is defined by
198-
:func:`process_exception`. Refer to its documentation for details.
197+
:func:`~websockets.client.process_exception`. Refer to its
198+
documentation for details.
199199
open_timeout: Timeout for opening the connection in seconds.
200200
:obj:`None` disables the timeout.
201201
ping_interval: Interval between keepalive pings in seconds.

tests/test_client.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from unittest.mock import patch
77

88
from websockets.client import *
9-
from websockets.client import backoff
9+
from websockets.client import backoff, process_exception
1010
from websockets.datastructures import Headers
1111
from websockets.exceptions import (
1212
InvalidHandshake,
@@ -684,6 +684,32 @@ def test_client_connection_class(self):
684684
self.assertIsInstance(client, ClientProtocol)
685685

686686

687+
class ProcessExceptionTests(unittest.TestCase):
688+
def test_process_exception_retryable(self):
689+
"""process_exception(exc) returns None on retriable errors."""
690+
connection_closed_exc = InvalidMessage("did not receive a valid HTTP response")
691+
connection_closed_exc.__cause__ = EOFError(
692+
"connection closed while reading HTTP status line"
693+
)
694+
for exc in [
695+
ConnectionRefusedError(61, "Connection refused"),
696+
TimeoutError("timed out while waiting for handshake response"),
697+
connection_closed_exc,
698+
InvalidStatus(Response(503, "Service Unavailable", Headers(), b"")),
699+
]:
700+
with self.subTest(exc=exc):
701+
self.assertIsNone(process_exception(exc))
702+
703+
def test_process_exception_fatal(self):
704+
"""process_exception(exc) returns exc on fatal errors."""
705+
for exc in [
706+
InvalidStatus(Response(410, "Gone", Headers(), b"")),
707+
AssertionError("unexpected error"),
708+
]:
709+
with self.subTest(exc=exc):
710+
self.assertIs(process_exception(exc), exc)
711+
712+
687713
class BackoffTests(unittest.TestCase):
688714
def test_backoff(self):
689715
"""backoff() yields a random delay, then exponentially increasing delays."""

0 commit comments

Comments
 (0)