-
-
Notifications
You must be signed in to change notification settings - Fork 33
Support strict_exception_groups=True #188
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
8f04a5c
7eb779b
c3f7dc2
8fd6db1
69853c3
f22fa75
4e6f1c7
5560766
a9f14a1
40369d0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,5 +4,4 @@ pip-tools>=5.5.0 | |
| pytest>=4.6 | ||
| pytest-cov | ||
| pytest-trio>=0.5.0 | ||
| trio<0.25 | ||
| trustme | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| # requirements for `make lint/docs/publish` | ||
| mypy | ||
| pylint | ||
| mypy | ||
| sphinx | ||
| sphinxcontrib-trio | ||
| sphinx_rtd_theme | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi John-- I may be slow in reviewing. Please make sure the PR description summarizes the changes (e.g. mentions the internal error, API policy about exception groups, etc.). Would need test coverage for any changes, but ok to hold off until I've reviewed the basics (as I can't promise we'll take the direction of this PR).
Please see #177. On type checking, I'm ok running a loose mypy pass, but reluctant to convert the codebase to strict type annotations, since fighting the type system is often a burden on maintenance (especially with a project on life support).
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah thank you, of course I'd complained about lack of Black before 😅 Agree on loose mypy just to check if the annotations that exist are correct. And I don't have any plans on adding annotations everywhere, just doing it for my own sake when developing. Will try and make PR summary verbose and descriptive 👍
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In principle I'd like to state that the API doesn't raise exception groups, and that would include the server API. For the user's handler, perhaps add
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that for users that are embracing |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,7 @@ | |
| import urllib.parse | ||
| from typing import Iterable, List, Optional, Union | ||
|
|
||
| import outcome | ||
| import trio | ||
| import trio.abc | ||
| from wsproto import ConnectionType, WSConnection | ||
|
|
@@ -44,6 +45,13 @@ | |
| logger = logging.getLogger('trio-websocket') | ||
|
|
||
|
|
||
| class TrioWebsocketInternalError(Exception): | ||
| """Raised as a fallback when open_websocket is unable to unwind an exceptiongroup | ||
| into a single preferred exception. This should never happen, if it does then | ||
| underlying assumptions about the internal code are incorrect. | ||
| """ | ||
|
|
||
|
|
||
|
Comment on lines
+48
to
+59
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. needs docs
It seems odd to raise an internal error where a user-context exception is implicated. Perhaps we can discard the internal error with a warning message, and propagate the user exception.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The "internalerror" bit would be that we're encountering a state that the internal logic assumed to be impossible - similar to TrioInternalError. I should've added a message to the exception to make that clearer.
I quite like that solution. Only possible problem would be if this makes exception handling harder, but it 1. shouldn't happen, 2. they can still handle the user exception. We can also log detailed info on the exception we're suppressing, so the warning can be relatively brief. |
||
| def _ignore_cancel(exc): | ||
| return None if isinstance(exc, trio.Cancelled) else exc | ||
|
|
||
|
|
@@ -125,10 +133,33 @@ async def open_websocket( | |
| client-side timeout (:exc:`ConnectionTimeout`, :exc:`DisconnectionTimeout`), | ||
| or server rejection (:exc:`ConnectionRejected`) during handshakes. | ||
| ''' | ||
| async with trio.open_nursery() as new_nursery: | ||
|
|
||
| # This context manager tries very very hard not to raise an exceptiongroup | ||
| # in order to be as transparent as possible for the end user. | ||
| # In the trivial case, this means that if user code inside the cm raises | ||
| # we make sure that it doesn't get wrapped. | ||
|
|
||
| # If opening the connection fails, then we will raise that exception. User | ||
| # code is never executed, so we will never have multiple exceptions. | ||
|
|
||
| # After opening the connection, we spawn _reader_task in the background and | ||
| # yield to user code. If only one of those raise a non-cancelled exception | ||
| # we will raise that non-cancelled exception. | ||
| # If we get multiple cancelled, we raise the user's cancelled. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Multiple Cancelled was an existing possible case, so do we need to try hard to make it a single exception? (Cancelled is typically caught by trio itself, and otherwise the user had to deal with MultiError Cancelled anyway.)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The problem is that it's a very easy gotcha to write try:
...
except Cancelled:
...which might work 99% of the time, but then at some random point you get multiple instead of a single (or the other way around)
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MultiError with Cancelled can happen fairly easily, and we quickly learned never to write By this trio-websocket change, we're masking the user from the effects of a trio anti-pattern, which is concerning. For example, the user may get away with
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hrm, I think this would be a very bad design choice. With this PR we're trying to make the internal nursery an implementation detail, as per https://trio.readthedocs.io/en/stable/reference-core.html#designing-for-multiple-errors In the future the users shouldn't need to worry about this anti-pattern at all, so I do not think we should enshrine it in this API. |
||
| # If both raise exceptions, we raise the user code's exception with the entire | ||
| # exception group as the __cause__. | ||
| # If we somehow get multiple exceptions, but no user exception, then we raise | ||
| # TrioWebsocketInternalError. | ||
|
|
||
| # If closing the connection fails, then that will be raised as the top | ||
| # exception in the last `finally`. If we encountered exceptions in user code | ||
| # or in reader task then they will be set as the `__cause__`. | ||
|
|
||
|
|
||
| async def open_connection(nursery: trio.Nursery) -> WebSocketConnection: | ||
|
belm0 marked this conversation as resolved.
Outdated
|
||
| try: | ||
| with trio.fail_after(connect_timeout): | ||
| connection = await connect_websocket(new_nursery, host, port, | ||
| return await connect_websocket(nursery, host, port, | ||
| resource, use_ssl=use_ssl, subprotocols=subprotocols, | ||
| extra_headers=extra_headers, | ||
| message_queue_size=message_queue_size, | ||
|
|
@@ -137,14 +168,90 @@ async def open_websocket( | |
| raise ConnectionTimeout from None | ||
| except OSError as e: | ||
| raise HandshakeError from e | ||
|
|
||
| async def close_connection(connection: WebSocketConnection) -> None: | ||
| try: | ||
| yield connection | ||
| finally: | ||
| try: | ||
| with trio.fail_after(disconnect_timeout): | ||
| await connection.aclose() | ||
| except trio.TooSlowError: | ||
| raise DisconnectionTimeout from None | ||
| with trio.fail_after(disconnect_timeout): | ||
| await connection.aclose() | ||
| except trio.TooSlowError: | ||
| raise DisconnectionTimeout from None | ||
|
|
||
| if _TRIO_MULTI_ERROR: | ||
| exception_group_type = trio.MultiError # type: ignore[attr-defined] # pylint: disable=no-member | ||
| else: | ||
| exception_group_type = BaseExceptionGroup | ||
|
belm0 marked this conversation as resolved.
Outdated
|
||
|
|
||
| connection: WebSocketConnection|None=None | ||
| result2: outcome.Maybe[None] | None = None | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I forgot if we discussed already, but I think
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I also think it's nice for newbies not to have another keyword to know, and highly personally I prefer not having to do another import for it.
jakkdl marked this conversation as resolved.
Outdated
|
||
| user_error = None | ||
|
|
||
| try: | ||
| async with trio.open_nursery() as new_nursery: | ||
| result = await outcome.acapture(open_connection, new_nursery) | ||
|
|
||
| if isinstance(result, outcome.Value): | ||
| connection = result.unwrap() | ||
| try: | ||
| yield connection | ||
| except BaseException as e: | ||
| user_error = e | ||
| raise | ||
| finally: | ||
| result2 = await outcome.acapture(close_connection, connection) | ||
| # This exception handler should only be entered if either: | ||
| # 1. The _reader_task started in connect_websocket raises | ||
| # 2. User code raises an exception | ||
| # I.e. open/close_connection are not included | ||
| except exception_group_type as e: | ||
| # user_error, or exception bubbling up from _reader_task | ||
| if len(e.exceptions) == 1: | ||
| raise e.exceptions[0] | ||
|
|
||
| # contains at most 1 non-cancelled exceptions | ||
| exception_to_raise: BaseException|None = None | ||
| for sub_exc in e.exceptions: | ||
| if not isinstance(sub_exc, trio.Cancelled): | ||
| if exception_to_raise is not None: | ||
| # multiple non-cancelled | ||
| break | ||
| exception_to_raise = sub_exc | ||
| else: | ||
| if exception_to_raise is None: | ||
| # all exceptions are cancelled | ||
| # prefer raising the one from the user, for traceback reasons | ||
| if user_error is not None: | ||
| # no reason to raise from e, just to include a bunch of extra | ||
| # cancelleds. | ||
| raise user_error # pylint: disable=raise-missing-from | ||
| # multiple internal Cancelled is not possible afaik | ||
| raise e.exceptions[0] # pragma: no cover # pylint: disable=raise-missing-from | ||
| raise exception_to_raise | ||
|
|
||
| # if we have any KeyboardInterrupt in the group, make sure to raise it. | ||
| for sub_exc in e.exceptions: | ||
| if isinstance(sub_exc, KeyboardInterrupt): | ||
| raise sub_exc from e | ||
|
|
||
| # Both user code and internal code raised non-cancelled exceptions. | ||
| # We "hide" the internal exception(s) in the __cause__ and surface | ||
| # the user_error. | ||
| if user_error is not None: | ||
| raise user_error from e | ||
|
|
||
| raise TrioWebsocketInternalError( | ||
| "Multiple internal exceptions should not be possible. " | ||
|
jakkdl marked this conversation as resolved.
Outdated
|
||
| "Please report this as a bug to " | ||
| "https://github.com/python-trio/trio-websocket" | ||
| ) from e # pragma: no cover | ||
|
|
||
| finally: | ||
| if result2 is not None: | ||
| result2.unwrap() | ||
|
|
||
|
|
||
| # error setting up, unwrap that exception | ||
| if connection is None: | ||
| result.unwrap() | ||
|
|
||
|
|
||
| async def connect_websocket(nursery, host, port, resource, *, use_ssl, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.