Skip to content

Commit d4322e7

Browse files
authored
[PR #8089/dc38630b backport][3.10] 💅 Propagate error causes via asyncio protocols (#8161)
**This is a backport of PR #8089 as merged into master (dc38630).** This is supposed to unify setting exceptions on the future objects, allowing to also attach their causes whenever available. It'll make possible for the end-users to see more detailed tracebacks. It's also supposed to help with tracking down what's happening with #4581. PR #8089 Co-Authored-By: J. Nick Koston <[email protected]> Co-Authored-By: Sam Bull <[email protected]> (cherry picked from commit dc38630)
1 parent 6cb21d1 commit d4322e7

15 files changed

+177
-66
lines changed

CHANGES/8089.bugfix.rst

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
The asynchronous internals now set the underlying causes
2+
when assigning exceptions to the future objects
3+
-- by :user:`webknjaz`.

aiohttp/_http_parser.pyx

+7-5
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ from multidict import CIMultiDict as _CIMultiDict, CIMultiDictProxy as _CIMultiD
1919
from yarl import URL as _URL
2020

2121
from aiohttp import hdrs
22-
from aiohttp.helpers import DEBUG
22+
from aiohttp.helpers import DEBUG, set_exception
2323

2424
from .http_exceptions import (
2525
BadHttpMessage,
@@ -763,11 +763,13 @@ cdef int cb_on_body(cparser.llhttp_t* parser,
763763
cdef bytes body = at[:length]
764764
try:
765765
pyparser._payload.feed_data(body, length)
766-
except BaseException as exc:
766+
except BaseException as underlying_exc:
767+
reraised_exc = underlying_exc
767768
if pyparser._payload_exception is not None:
768-
pyparser._payload.set_exception(pyparser._payload_exception(str(exc)))
769-
else:
770-
pyparser._payload.set_exception(exc)
769+
reraised_exc = pyparser._payload_exception(str(underlying_exc))
770+
771+
set_exception(pyparser._payload, reraised_exc, underlying_exc)
772+
771773
pyparser._payload_error = 1
772774
return -1
773775
else:

aiohttp/base_protocol.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
from typing import Optional, cast
33

4+
from .helpers import set_exception
45
from .tcp_helpers import tcp_nodelay
56

67

@@ -76,7 +77,11 @@ def connection_lost(self, exc: Optional[BaseException]) -> None:
7677
if exc is None:
7778
waiter.set_result(None)
7879
else:
79-
waiter.set_exception(exc)
80+
set_exception(
81+
waiter,
82+
ConnectionError("Connection lost"),
83+
exc,
84+
)
8085

8186
async def _drain_helper(self) -> None:
8287
if not self.connected:

aiohttp/client_proto.py

+49-17
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,14 @@
99
ServerDisconnectedError,
1010
SocketTimeoutError,
1111
)
12-
from .helpers import BaseTimerContext, status_code_must_be_empty_body
12+
from .helpers import (
13+
_EXC_SENTINEL,
14+
BaseTimerContext,
15+
set_exception,
16+
status_code_must_be_empty_body,
17+
)
1318
from .http import HttpResponseParser, RawResponseMessage
19+
from .http_exceptions import HttpProcessingError
1420
from .streams import EMPTY_PAYLOAD, DataQueue, StreamReader
1521

1622

@@ -73,36 +79,58 @@ def is_connected(self) -> bool:
7379
def connection_lost(self, exc: Optional[BaseException]) -> None:
7480
self._drop_timeout()
7581

82+
original_connection_error = exc
83+
reraised_exc = original_connection_error
84+
85+
connection_closed_cleanly = original_connection_error is None
86+
7687
if self._payload_parser is not None:
77-
with suppress(Exception):
88+
with suppress(Exception): # FIXME: log this somehow?
7889
self._payload_parser.feed_eof()
7990

8091
uncompleted = None
8192
if self._parser is not None:
8293
try:
8394
uncompleted = self._parser.feed_eof()
84-
except Exception as e:
95+
except Exception as underlying_exc:
8596
if self._payload is not None:
86-
exc = ClientPayloadError("Response payload is not completed")
87-
exc.__cause__ = e
88-
self._payload.set_exception(exc)
97+
client_payload_exc_msg = (
98+
f"Response payload is not completed: {underlying_exc !r}"
99+
)
100+
if not connection_closed_cleanly:
101+
client_payload_exc_msg = (
102+
f"{client_payload_exc_msg !s}. "
103+
f"{original_connection_error !r}"
104+
)
105+
set_exception(
106+
self._payload,
107+
ClientPayloadError(client_payload_exc_msg),
108+
underlying_exc,
109+
)
89110

90111
if not self.is_eof():
91-
if isinstance(exc, OSError):
92-
exc = ClientOSError(*exc.args)
93-
if exc is None:
94-
exc = ServerDisconnectedError(uncompleted)
112+
if isinstance(original_connection_error, OSError):
113+
reraised_exc = ClientOSError(*original_connection_error.args)
114+
if connection_closed_cleanly:
115+
reraised_exc = ServerDisconnectedError(uncompleted)
95116
# assigns self._should_close to True as side effect,
96117
# we do it anyway below
97-
self.set_exception(exc)
118+
underlying_non_eof_exc = (
119+
_EXC_SENTINEL
120+
if connection_closed_cleanly
121+
else original_connection_error
122+
)
123+
assert underlying_non_eof_exc is not None
124+
assert reraised_exc is not None
125+
self.set_exception(reraised_exc, underlying_non_eof_exc)
98126

99127
self._should_close = True
100128
self._parser = None
101129
self._payload = None
102130
self._payload_parser = None
103131
self._reading_paused = False
104132

105-
super().connection_lost(exc)
133+
super().connection_lost(reraised_exc)
106134

107135
def eof_received(self) -> None:
108136
# should call parser.feed_eof() most likely
@@ -116,10 +144,14 @@ def resume_reading(self) -> None:
116144
super().resume_reading()
117145
self._reschedule_timeout()
118146

119-
def set_exception(self, exc: BaseException) -> None:
147+
def set_exception(
148+
self,
149+
exc: BaseException,
150+
exc_cause: BaseException = _EXC_SENTINEL,
151+
) -> None:
120152
self._should_close = True
121153
self._drop_timeout()
122-
super().set_exception(exc)
154+
super().set_exception(exc, exc_cause)
123155

124156
def set_parser(self, parser: Any, payload: Any) -> None:
125157
# TODO: actual types are:
@@ -196,7 +228,7 @@ def _on_read_timeout(self) -> None:
196228
exc = SocketTimeoutError("Timeout on reading data from socket")
197229
self.set_exception(exc)
198230
if self._payload is not None:
199-
self._payload.set_exception(exc)
231+
set_exception(self._payload, exc)
200232

201233
def data_received(self, data: bytes) -> None:
202234
self._reschedule_timeout()
@@ -222,14 +254,14 @@ def data_received(self, data: bytes) -> None:
222254
# parse http messages
223255
try:
224256
messages, upgraded, tail = self._parser.feed_data(data)
225-
except BaseException as exc:
257+
except BaseException as underlying_exc:
226258
if self.transport is not None:
227259
# connection.release() could be called BEFORE
228260
# data_received(), the transport is already
229261
# closed in this case
230262
self.transport.close()
231263
# should_close is True after the call
232-
self.set_exception(exc)
264+
self.set_exception(HttpProcessingError(), underlying_exc)
233265
return
234266

235267
self._upgraded = upgraded

aiohttp/client_reqrep.py

+22-12
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
netrc_from_env,
5151
noop,
5252
reify,
53+
set_exception,
5354
set_result,
5455
)
5556
from .http import (
@@ -630,20 +631,29 @@ async def write_bytes(
630631

631632
for chunk in self.body:
632633
await writer.write(chunk) # type: ignore[arg-type]
633-
except OSError as exc:
634-
if exc.errno is None and isinstance(exc, asyncio.TimeoutError):
635-
protocol.set_exception(exc)
636-
else:
637-
new_exc = ClientOSError(
638-
exc.errno, "Can not write request body for %s" % self.url
634+
except OSError as underlying_exc:
635+
reraised_exc = underlying_exc
636+
637+
exc_is_not_timeout = underlying_exc.errno is not None or not isinstance(
638+
underlying_exc, asyncio.TimeoutError
639+
)
640+
if exc_is_not_timeout:
641+
reraised_exc = ClientOSError(
642+
underlying_exc.errno,
643+
f"Can not write request body for {self.url !s}",
639644
)
640-
new_exc.__context__ = exc
641-
new_exc.__cause__ = exc
642-
protocol.set_exception(new_exc)
645+
646+
set_exception(protocol, reraised_exc, underlying_exc)
643647
except asyncio.CancelledError:
644648
await writer.write_eof()
645-
except Exception as exc:
646-
protocol.set_exception(exc)
649+
except Exception as underlying_exc:
650+
set_exception(
651+
protocol,
652+
ClientConnectionError(
653+
f"Failed to send bytes into the underlying connection {conn !s}",
654+
),
655+
underlying_exc,
656+
)
647657
else:
648658
await writer.write_eof()
649659
protocol.start_timeout()
@@ -1086,7 +1096,7 @@ def _cleanup_writer(self) -> None:
10861096
def _notify_content(self) -> None:
10871097
content = self.content
10881098
if content and content.exception() is None:
1089-
content.set_exception(ClientConnectionError("Connection closed"))
1099+
set_exception(content, ClientConnectionError("Connection closed"))
10901100
self._released = True
10911101

10921102
async def wait_for_close(self) -> None:

aiohttp/helpers.py

+33-3
Original file line numberDiff line numberDiff line change
@@ -810,9 +810,39 @@ def set_result(fut: "asyncio.Future[_T]", result: _T) -> None:
810810
fut.set_result(result)
811811

812812

813-
def set_exception(fut: "asyncio.Future[_T]", exc: BaseException) -> None:
814-
if not fut.done():
815-
fut.set_exception(exc)
813+
_EXC_SENTINEL = BaseException()
814+
815+
816+
class ErrorableProtocol(Protocol):
817+
def set_exception(
818+
self,
819+
exc: BaseException,
820+
exc_cause: BaseException = ...,
821+
) -> None:
822+
... # pragma: no cover
823+
824+
825+
def set_exception(
826+
fut: "asyncio.Future[_T] | ErrorableProtocol",
827+
exc: BaseException,
828+
exc_cause: BaseException = _EXC_SENTINEL,
829+
) -> None:
830+
"""Set future exception.
831+
832+
If the future is marked as complete, this function is a no-op.
833+
834+
:param exc_cause: An exception that is a direct cause of ``exc``.
835+
Only set if provided.
836+
"""
837+
if asyncio.isfuture(fut) and fut.done():
838+
return
839+
840+
exc_is_sentinel = exc_cause is _EXC_SENTINEL
841+
exc_causes_itself = exc is exc_cause
842+
if not exc_is_sentinel and not exc_causes_itself:
843+
exc.__cause__ = exc_cause
844+
845+
fut.set_exception(exc)
816846

817847

818848
@functools.total_ordering

aiohttp/http_parser.py

+18-9
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@
2828
from .base_protocol import BaseProtocol
2929
from .compression_utils import HAS_BROTLI, BrotliDecompressor, ZLibDecompressor
3030
from .helpers import (
31+
_EXC_SENTINEL,
3132
DEBUG,
3233
NO_EXTENSIONS,
3334
BaseTimerContext,
3435
method_must_be_empty_body,
36+
set_exception,
3537
status_code_must_be_empty_body,
3638
)
3739
from .http_exceptions import (
@@ -446,13 +448,16 @@ def get_content_length() -> Optional[int]:
446448
assert self._payload_parser is not None
447449
try:
448450
eof, data = self._payload_parser.feed_data(data[start_pos:], SEP)
449-
except BaseException as exc:
451+
except BaseException as underlying_exc:
452+
reraised_exc = underlying_exc
450453
if self.payload_exception is not None:
451-
self._payload_parser.payload.set_exception(
452-
self.payload_exception(str(exc))
453-
)
454-
else:
455-
self._payload_parser.payload.set_exception(exc)
454+
reraised_exc = self.payload_exception(str(underlying_exc))
455+
456+
set_exception(
457+
self._payload_parser.payload,
458+
reraised_exc,
459+
underlying_exc,
460+
)
456461

457462
eof = True
458463
data = b""
@@ -834,7 +839,7 @@ def feed_data(
834839
exc = TransferEncodingError(
835840
chunk[:pos].decode("ascii", "surrogateescape")
836841
)
837-
self.payload.set_exception(exc)
842+
set_exception(self.payload, exc)
838843
raise exc
839844
size = int(bytes(size_b), 16)
840845

@@ -939,8 +944,12 @@ def __init__(self, out: StreamReader, encoding: Optional[str]) -> None:
939944
else:
940945
self.decompressor = ZLibDecompressor(encoding=encoding)
941946

942-
def set_exception(self, exc: BaseException) -> None:
943-
self.out.set_exception(exc)
947+
def set_exception(
948+
self,
949+
exc: BaseException,
950+
exc_cause: BaseException = _EXC_SENTINEL,
951+
) -> None:
952+
set_exception(self.out, exc, exc_cause)
944953

945954
def feed_data(self, chunk: bytes, size: int) -> None:
946955
if not size:

aiohttp/http_websocket.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
from .base_protocol import BaseProtocol
2727
from .compression_utils import ZLibCompressor, ZLibDecompressor
28-
from .helpers import NO_EXTENSIONS
28+
from .helpers import NO_EXTENSIONS, set_exception
2929
from .streams import DataQueue
3030

3131
__all__ = (
@@ -314,7 +314,7 @@ def feed_data(self, data: bytes) -> Tuple[bool, bytes]:
314314
return self._feed_data(data)
315315
except Exception as exc:
316316
self._exc = exc
317-
self.queue.set_exception(exc)
317+
set_exception(self.queue, exc)
318318
return True, b""
319319

320320
def _feed_data(self, data: bytes) -> Tuple[bool, bytes]:

0 commit comments

Comments
 (0)