1111import ssl
1212import struct
1313import urllib .parse
14- from typing import Iterable , List , Optional , Union
14+ from typing import Iterable , List , NoReturn , Optional , Union
1515
1616import outcome
1717import trio
@@ -151,14 +151,14 @@ async def open_websocket(
151151 # yield to user code. If only one of those raise a non-cancelled exception
152152 # we will raise that non-cancelled exception.
153153 # If we get multiple cancelled, we raise the user's cancelled.
154- # If both raise exceptions, we raise the user code's exception with the entire
155- # exception group as the __cause__.
154+ # If both raise exceptions, we raise the user code's exception with __context__
155+ # set to a group containing internal exception(s) + any user exception __context__
156156 # If we somehow get multiple exceptions, but no user exception, then we raise
157157 # TrioWebsocketInternalError.
158158
159159 # If closing the connection fails, then that will be raised as the top
160160 # exception in the last `finally`. If we encountered exceptions in user code
161- # or in reader task then they will be set as the `__cause__ `.
161+ # or in reader task then they will be set as the `__context__ `.
162162
163163
164164 async def _open_connection (nursery : trio .Nursery ) -> WebSocketConnection :
@@ -181,10 +181,27 @@ async def _close_connection(connection: WebSocketConnection) -> None:
181181 except trio .TooSlowError :
182182 raise DisconnectionTimeout from None
183183
184+ def _raise (exc : BaseException ) -> NoReturn :
185+ """This helper allows re-raising an exception without __context__ being set."""
186+ # cause does not need special handlng, we simply avoid using `raise .. from ..`
187+ __tracebackhide__ = True
188+ context = exc .__context__
189+ try :
190+ raise exc
191+ finally :
192+ exc .__context__ = context
193+ del exc , context
194+
184195 connection : WebSocketConnection | None = None
185196 close_result : outcome .Maybe [None ] | None = None
186197 user_error = None
187198
199+ # Unwrapping exception groups has a lot of pitfalls, one of them stemming from
200+ # the exception we raise also being inside the group that's set as the context.
201+ # This leads to loss of info unless properly handled.
202+ # See https://github.com/python-trio/flake8-async/issues/298
203+ # We therefore avoid having the exceptiongroup included as either cause or context
204+
188205 try :
189206 async with trio .open_nursery () as new_nursery :
190207 result = await outcome .acapture (_open_connection , new_nursery )
@@ -205,7 +222,7 @@ async def _close_connection(connection: WebSocketConnection) -> None:
205222 except _TRIO_EXC_GROUP_TYPE as e :
206223 # user_error, or exception bubbling up from _reader_task
207224 if len (e .exceptions ) == 1 :
208- raise e .exceptions [0 ]
225+ _raise ( e .exceptions [0 ])
209226
210227 # contains at most 1 non-cancelled exceptions
211228 exception_to_raise : BaseException | None = None
@@ -218,25 +235,40 @@ async def _close_connection(connection: WebSocketConnection) -> None:
218235 else :
219236 if exception_to_raise is None :
220237 # all exceptions are cancelled
221- # prefer raising the one from the user, for traceback reasons
238+ # we reraise the user exception and throw out internal
222239 if user_error is not None :
223- # no reason to raise from e, just to include a bunch of extra
224- # cancelleds.
225- raise user_error # pylint: disable=raise-missing-from
240+ _raise (user_error )
226241 # multiple internal Cancelled is not possible afaik
227- raise e .exceptions [0 ] # pragma: no cover # pylint: disable=raise-missing-from
228- raise exception_to_raise
242+ # but if so we just raise one of them
243+ _raise (e .exceptions [0 ]) # pragma: no cover
244+ # raise the non-cancelled exception
245+ _raise (exception_to_raise )
229246
230- # if we have any KeyboardInterrupt in the group, make sure to raise it.
247+ # if we have any KeyboardInterrupt in the group, raise a new KeyboardInterrupt
248+ # with the group as cause & context
231249 for sub_exc in e .exceptions :
232250 if isinstance (sub_exc , KeyboardInterrupt ):
233- raise sub_exc from e
251+ raise KeyboardInterrupt from e
234252
235253 # Both user code and internal code raised non-cancelled exceptions.
236- # We "hide" the internal exception(s) in the __cause__ and surface
237- # the user_error.
254+ # We set the context to be an exception group containing internal exceptions
255+ # and, if not None, ` user_error.__context__`
238256 if user_error is not None :
239- raise user_error from e
257+ exceptions = [subexc for subexc in e .exceptions if subexc is not user_error ]
258+ eg_substr = ''
259+ # there's technically loss of info here, with __suppress_context__=True you
260+ # still have original __context__ available, just not printed. But we delete
261+ # it completely because we can't partially suppress the group
262+ if user_error .__context__ is not None and not user_error .__suppress_context__ :
263+ exceptions .append (user_error .__context__ )
264+ eg_substr = ' and the context for the user exception'
265+ eg_str = (
266+ "Both internal and user exceptions encountered. This group contains "
267+ "the internal exception(s)" + eg_substr + "."
268+ )
269+ user_error .__context__ = BaseExceptionGroup (eg_str , exceptions )
270+ user_error .__suppress_context__ = False
271+ _raise (user_error )
240272
241273 raise TrioWebsocketInternalError (
242274 "The trio-websocket API is not expected to raise multiple exceptions. "
@@ -576,7 +608,7 @@ def __init__(self, reason):
576608 :param reason:
577609 :type reason: CloseReason
578610 '''
579- super ().__init__ ()
611+ super ().__init__ (reason )
580612 self .reason = reason
581613
582614 def __repr__ (self ):
@@ -596,7 +628,7 @@ def __init__(self, status_code, headers, body):
596628 :param reason:
597629 :type reason: CloseReason
598630 '''
599- super ().__init__ ()
631+ super ().__init__ (status_code , headers , body )
600632 #: a 3 digit HTTP status code
601633 self .status_code = status_code
602634 #: a tuple of 2-tuples containing header key/value pairs
0 commit comments