diff --git a/httpx/_client.py b/httpx/_client.py index 906eb7dad8..de4235118c 100644 --- a/httpx/_client.py +++ b/httpx/_client.py @@ -18,11 +18,10 @@ ) from ._decoders import SUPPORTED_DECODERS from ._exceptions import ( - HTTPCORE_EXC_MAP, InvalidURL, RemoteProtocolError, TooManyRedirects, - map_exceptions, + request_context, ) from ._models import URL, Cookies, Headers, QueryParams, Request, Response from ._status_codes import codes @@ -849,7 +848,7 @@ def _send_single_request(self, request: Request, timeout: Timeout) -> Response: timer = Timer() timer.sync_start() - with map_exceptions(HTTPCORE_EXC_MAP, request=request): + with request_context(request=request): (status_code, headers, stream, extensions) = transport.handle_request( request.method.encode(), request.url.raw, @@ -860,13 +859,13 @@ def _send_single_request(self, request: Request, timeout: Timeout) -> Response: def on_close(response: Response) -> None: response.elapsed = datetime.timedelta(seconds=timer.sync_elapsed()) - if hasattr(stream, "close"): - stream.close() # type: ignore + if "close" in extensions: + extensions["close"]() response = Response( status_code, headers=headers, - stream=stream, # type: ignore + stream=stream, extensions=extensions, request=request, on_close=on_close, @@ -1483,7 +1482,7 @@ async def _send_single_request( timer = Timer() await timer.async_start() - with map_exceptions(HTTPCORE_EXC_MAP, request=request): + with request_context(request=request): ( status_code, headers, @@ -1499,14 +1498,13 @@ async def _send_single_request( async def on_close(response: Response) -> None: response.elapsed = datetime.timedelta(seconds=await timer.async_elapsed()) - if hasattr(stream, "aclose"): - with map_exceptions(HTTPCORE_EXC_MAP, request=request): - await stream.aclose() # type: ignore + if "aclose" in extensions: + await extensions["aclose"]() response = Response( status_code, headers=headers, - stream=stream, # type: ignore + stream=stream, extensions=extensions, request=request, on_close=on_close, diff --git a/httpx/_decoders.py b/httpx/_decoders.py index 8ef0157e6f..c0d51a4cdc 100644 --- a/httpx/_decoders.py +++ b/httpx/_decoders.py @@ -8,6 +8,8 @@ import typing import zlib +from ._exceptions import DecodingError + try: import brotli except ImportError: # pragma: nocover @@ -54,13 +56,13 @@ def decode(self, data: bytes) -> bytes: if was_first_attempt: self.decompressor = zlib.decompressobj(-zlib.MAX_WBITS) return self.decode(data) - raise ValueError(str(exc)) + raise DecodingError(str(exc)) from exc def flush(self) -> bytes: try: return self.decompressor.flush() except zlib.error as exc: # pragma: nocover - raise ValueError(str(exc)) + raise DecodingError(str(exc)) from exc class GZipDecoder(ContentDecoder): @@ -77,13 +79,13 @@ def decode(self, data: bytes) -> bytes: try: return self.decompressor.decompress(data) except zlib.error as exc: - raise ValueError(str(exc)) + raise DecodingError(str(exc)) from exc def flush(self) -> bytes: try: return self.decompressor.flush() except zlib.error as exc: # pragma: nocover - raise ValueError(str(exc)) + raise DecodingError(str(exc)) from exc class BrotliDecoder(ContentDecoder): @@ -118,7 +120,7 @@ def decode(self, data: bytes) -> bytes: try: return self._decompress(data) except brotli.error as exc: - raise ValueError(str(exc)) + raise DecodingError(str(exc)) from exc def flush(self) -> bytes: if not self.seen_data: @@ -128,7 +130,7 @@ def flush(self) -> bytes: self.decompressor.finish() return b"" except brotli.error as exc: # pragma: nocover - raise ValueError(str(exc)) + raise DecodingError(str(exc)) from exc class MultiDecoder(ContentDecoder): diff --git a/httpx/_exceptions.py b/httpx/_exceptions.py index bade9f9b81..092dbcf04e 100644 --- a/httpx/_exceptions.py +++ b/httpx/_exceptions.py @@ -34,8 +34,6 @@ import contextlib import typing -import httpcore - if typing.TYPE_CHECKING: from ._models import Request, Response # pragma: nocover @@ -58,9 +56,8 @@ class HTTPError(Exception): ``` """ - def __init__(self, message: str, *, request: "Request") -> None: + def __init__(self, message: str) -> None: super().__init__(message) - self.request = request class RequestError(HTTPError): @@ -68,15 +65,30 @@ class RequestError(HTTPError): Base class for all exceptions that may occur when issuing a `.request()`. """ - def __init__(self, message: str, *, request: "Request") -> None: - super().__init__(message, request=request) + def __init__(self, message: str, *, request: "Request" = None) -> None: + super().__init__(message) + # At the point an exception is raised we won't typically have a request + # instance to associate it with. + # + # The 'request_context' context manager is used within the Client and + # Response methods in order to ensure that any raised exceptions + # have a `.request` property set on them. + self._request = request + + @property + def request(self) -> "Request": + if self._request is None: + raise RuntimeError("The .request property has not been set.") + return self._request + + @request.setter + def request(self, request: "Request") -> None: + self._request = request class TransportError(RequestError): """ Base class for all exceptions that occur at the level of the Transport API. - - All of these exceptions also have an equivelent mapping in `httpcore`. """ @@ -219,7 +231,8 @@ class HTTPStatusError(HTTPError): def __init__( self, message: str, *, request: "Request", response: "Response" ) -> None: - super().__init__(message, request=request) + super().__init__(message) + self.request = request self.response = response @@ -318,45 +331,14 @@ def __init__(self) -> None: @contextlib.contextmanager -def map_exceptions( - mapping: typing.Mapping[typing.Type[Exception], typing.Type[Exception]], - **kwargs: typing.Any, -) -> typing.Iterator[None]: +def request_context(request: "Request" = None) -> typing.Iterator[None]: + """ + A context manager that can be used to attach the given request context + to any `RequestError` exceptions that are raised within the block. + """ try: yield - except Exception as exc: - mapped_exc = None - - for from_exc, to_exc in mapping.items(): - if not isinstance(exc, from_exc): - continue - # We want to map to the most specific exception we can find. - # Eg if `exc` is an `httpcore.ReadTimeout`, we want to map to - # `httpx.ReadTimeout`, not just `httpx.TimeoutException`. - if mapped_exc is None or issubclass(to_exc, mapped_exc): - mapped_exc = to_exc - - if mapped_exc is None: - raise - - message = str(exc) - raise mapped_exc(message, **kwargs) from exc # type: ignore - - -HTTPCORE_EXC_MAP = { - httpcore.TimeoutException: TimeoutException, - httpcore.ConnectTimeout: ConnectTimeout, - httpcore.ReadTimeout: ReadTimeout, - httpcore.WriteTimeout: WriteTimeout, - httpcore.PoolTimeout: PoolTimeout, - httpcore.NetworkError: NetworkError, - httpcore.ConnectError: ConnectError, - httpcore.ReadError: ReadError, - httpcore.WriteError: WriteError, - httpcore.CloseError: CloseError, - httpcore.ProxyError: ProxyError, - httpcore.UnsupportedProtocol: UnsupportedProtocol, - httpcore.ProtocolError: ProtocolError, - httpcore.LocalProtocolError: LocalProtocolError, - httpcore.RemoteProtocolError: RemoteProtocolError, -} + except RequestError as exc: + if request is not None: + exc.request = request + raise exc diff --git a/httpx/_models.py b/httpx/_models.py index be6d4c27c9..871278fdbe 100644 --- a/httpx/_models.py +++ b/httpx/_models.py @@ -1,5 +1,4 @@ import cgi -import contextlib import datetime import email.message import json as jsonlib @@ -24,16 +23,14 @@ TextDecoder, ) from ._exceptions import ( - HTTPCORE_EXC_MAP, CookieConflict, - DecodingError, HTTPStatusError, InvalidURL, RequestNotRead, ResponseClosed, ResponseNotRead, StreamConsumed, - map_exceptions, + request_context, ) from ._status_codes import codes from ._types import ( @@ -1145,17 +1142,6 @@ def num_bytes_downloaded(self) -> int: def __repr__(self) -> str: return f"" - @contextlib.contextmanager - def _wrap_decoder_errors(self) -> typing.Iterator[None]: - # If the response has an associated request instance, we want decoding - # errors to be raised as proper `httpx.DecodingError` exceptions. - try: - yield - except ValueError as exc: - if self._request is None: - raise exc - raise DecodingError(message=str(exc), request=self.request) from exc - def read(self) -> bytes: """ Read and return the response content. @@ -1176,7 +1162,7 @@ def iter_bytes(self, chunk_size: int = None) -> typing.Iterator[bytes]: else: decoder = self._get_content_decoder() chunker = ByteChunker(chunk_size=chunk_size) - with self._wrap_decoder_errors(): + with request_context(request=self._request): for raw_bytes in self.iter_raw(): decoded = decoder.decode(raw_bytes) for chunk in chunker.decode(decoded): @@ -1195,7 +1181,7 @@ def iter_text(self, chunk_size: int = None) -> typing.Iterator[str]: """ decoder = TextDecoder(encoding=self.encoding) chunker = TextChunker(chunk_size=chunk_size) - with self._wrap_decoder_errors(): + with request_context(request=self._request): for byte_content in self.iter_bytes(): text_content = decoder.decode(byte_content) for chunk in chunker.decode(text_content): @@ -1208,7 +1194,7 @@ def iter_text(self, chunk_size: int = None) -> typing.Iterator[str]: def iter_lines(self) -> typing.Iterator[str]: decoder = LineDecoder() - with self._wrap_decoder_errors(): + with request_context(request=self._request): for text in self.iter_text(): for line in decoder.decode(text): yield line @@ -1230,7 +1216,7 @@ def iter_raw(self, chunk_size: int = None) -> typing.Iterator[bytes]: self._num_bytes_downloaded = 0 chunker = ByteChunker(chunk_size=chunk_size) - with map_exceptions(HTTPCORE_EXC_MAP, request=self._request): + with request_context(request=self._request): for raw_stream_bytes in self.stream: self._num_bytes_downloaded += len(raw_stream_bytes) for chunk in chunker.decode(raw_stream_bytes): @@ -1249,7 +1235,8 @@ def close(self) -> None: if not self.is_closed: self.is_closed = True if self._on_close is not None: - self._on_close(self) + with request_context(request=self._request): + self._on_close(self) async def aread(self) -> bytes: """ @@ -1271,7 +1258,7 @@ async def aiter_bytes(self, chunk_size: int = None) -> typing.AsyncIterator[byte else: decoder = self._get_content_decoder() chunker = ByteChunker(chunk_size=chunk_size) - with self._wrap_decoder_errors(): + with request_context(request=self._request): async for raw_bytes in self.aiter_raw(): decoded = decoder.decode(raw_bytes) for chunk in chunker.decode(decoded): @@ -1290,7 +1277,7 @@ async def aiter_text(self, chunk_size: int = None) -> typing.AsyncIterator[str]: """ decoder = TextDecoder(encoding=self.encoding) chunker = TextChunker(chunk_size=chunk_size) - with self._wrap_decoder_errors(): + with request_context(request=self._request): async for byte_content in self.aiter_bytes(): text_content = decoder.decode(byte_content) for chunk in chunker.decode(text_content): @@ -1303,7 +1290,7 @@ async def aiter_text(self, chunk_size: int = None) -> typing.AsyncIterator[str]: async def aiter_lines(self) -> typing.AsyncIterator[str]: decoder = LineDecoder() - with self._wrap_decoder_errors(): + with request_context(request=self._request): async for text in self.aiter_text(): for line in decoder.decode(text): yield line @@ -1325,7 +1312,7 @@ async def aiter_raw(self, chunk_size: int = None) -> typing.AsyncIterator[bytes] self._num_bytes_downloaded = 0 chunker = ByteChunker(chunk_size=chunk_size) - with map_exceptions(HTTPCORE_EXC_MAP, request=self._request): + with request_context(request=self._request): async for raw_stream_bytes in self.stream: self._num_bytes_downloaded += len(raw_stream_bytes) for chunk in chunker.decode(raw_stream_bytes): @@ -1344,7 +1331,8 @@ async def aclose(self) -> None: if not self.is_closed: self.is_closed = True if self._on_close is not None: - await self._on_close(self) + with request_context(request=self._request): + await self._on_close(self) class Cookies(MutableMapping): diff --git a/httpx/_transports/base.py b/httpx/_transports/base.py index bea96d014b..f20cdd32bd 100644 --- a/httpx/_transports/base.py +++ b/httpx/_transports/base.py @@ -52,8 +52,8 @@ def handle_request( try: body = b''.join([part for part in stream]) finally: - if hasattr(stream 'close'): - stream.close() + if 'close' in extensions: + extensions['close']() print(status_code, headers, body) Arguments: @@ -86,6 +86,10 @@ def handle_request( eg. the leading response bytes were b"HTTP/1.1 200 ". http_version: The HTTP version, as a string. Eg. "HTTP/1.1". When no http_version key is included, "HTTP/1.1" may be assumed. + close: A callback which should be invoked to release any network + resources. + aclose: An async callback which should be invoked to release any + network resources. """ raise NotImplementedError( "The 'handle_request' method must be implemented." diff --git a/httpx/_transports/default.py b/httpx/_transports/default.py index f687269585..dc0dd34877 100644 --- a/httpx/_transports/default.py +++ b/httpx/_transports/default.py @@ -24,12 +24,30 @@ transport = httpx.HTTPTransport(uds="socket.uds") client = httpx.Client(transport=transport) """ +import contextlib import typing from types import TracebackType import httpcore from .._config import DEFAULT_LIMITS, Limits, Proxy, create_ssl_context +from .._exceptions import ( + CloseError, + ConnectError, + ConnectTimeout, + LocalProtocolError, + NetworkError, + PoolTimeout, + ProtocolError, + ProxyError, + ReadError, + ReadTimeout, + RemoteProtocolError, + TimeoutException, + UnsupportedProtocol, + WriteError, + WriteTimeout, +) from .._types import CertTypes, VerifyTypes from .base import AsyncBaseTransport, BaseTransport @@ -37,6 +55,48 @@ A = typing.TypeVar("A", bound="AsyncHTTPTransport") +@contextlib.contextmanager +def map_httpcore_exceptions() -> typing.Iterator[None]: + try: + yield + except Exception as exc: + mapped_exc = None + + for from_exc, to_exc in HTTPCORE_EXC_MAP.items(): + if not isinstance(exc, from_exc): + continue + # We want to map to the most specific exception we can find. + # Eg if `exc` is an `httpcore.ReadTimeout`, we want to map to + # `httpx.ReadTimeout`, not just `httpx.TimeoutException`. + if mapped_exc is None or issubclass(to_exc, mapped_exc): + mapped_exc = to_exc + + if mapped_exc is None: # pragma: nocover + raise + + message = str(exc) + raise mapped_exc(message) from exc + + +HTTPCORE_EXC_MAP = { + httpcore.TimeoutException: TimeoutException, + httpcore.ConnectTimeout: ConnectTimeout, + httpcore.ReadTimeout: ReadTimeout, + httpcore.WriteTimeout: WriteTimeout, + httpcore.PoolTimeout: PoolTimeout, + httpcore.NetworkError: NetworkError, + httpcore.ConnectError: ConnectError, + httpcore.ReadError: ReadError, + httpcore.WriteError: WriteError, + httpcore.CloseError: CloseError, + httpcore.ProxyError: ProxyError, + httpcore.UnsupportedProtocol: UnsupportedProtocol, + httpcore.ProtocolError: ProtocolError, + httpcore.LocalProtocolError: LocalProtocolError, + httpcore.RemoteProtocolError: RemoteProtocolError, +} + + class HTTPTransport(BaseTransport): def __init__( self, @@ -100,7 +160,27 @@ def handle_request( ) -> typing.Tuple[ int, typing.List[typing.Tuple[bytes, bytes]], typing.Iterator[bytes], dict ]: - return self._pool.request(method, url, headers=headers, stream=stream, ext=extensions) # type: ignore + with map_httpcore_exceptions(): + status_code, headers, byte_stream, extensions = self._pool.request( + method=method, + url=url, + headers=headers, + stream=stream, # type: ignore + ext=extensions, + ) + + def response_stream() -> typing.Iterator[bytes]: + with map_httpcore_exceptions(): + for part in byte_stream: + yield part + + def close() -> None: + with map_httpcore_exceptions(): + byte_stream.close() + + extensions["close"] = close + + return status_code, headers, response_stream(), extensions def close(self) -> None: self._pool.close() @@ -169,9 +249,27 @@ async def handle_async_request( ) -> typing.Tuple[ int, typing.List[typing.Tuple[bytes, bytes]], typing.AsyncIterator[bytes], dict ]: - return await self._pool.arequest( # type: ignore - method, url, headers=headers, stream=stream, ext=extensions # type: ignore - ) + with map_httpcore_exceptions(): + status_code, headers, byte_stream, extenstions = await self._pool.arequest( + method=method, + url=url, + headers=headers, + stream=stream, # type: ignore + ext=extensions, + ) + + async def response_stream() -> typing.AsyncIterator[bytes]: + with map_httpcore_exceptions(): + async for part in byte_stream: + yield part + + async def aclose() -> None: + with map_httpcore_exceptions(): + await byte_stream.aclose() + + extensions["aclose"] = aclose + + return status_code, headers, response_stream(), extensions async def aclose(self) -> None: await self._pool.aclose() diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py index 42b612bfa7..99493c43ab 100644 --- a/tests/client/test_async_client.py +++ b/tests/client/test_async_client.py @@ -1,7 +1,6 @@ import typing from datetime import timedelta -import httpcore import pytest import httpx @@ -303,25 +302,6 @@ async def test_mounted_transport(): assert response.json() == {"app": "mounted"} -@pytest.mark.usefixtures("async_environment") -async def test_response_aclose_map_exceptions(): - class BrokenStream: - async def __aiter__(self): - # so we're an AsyncIterator - pass # pragma: nocover - - async def aclose(self): - raise httpcore.CloseError(OSError(104, "Connection reset by peer")) - - def handle(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, stream=BrokenStream()) - - async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client: - async with client.stream("GET", "http://example.com") as response: - with pytest.raises(httpx.CloseError): - await response.aclose() - - @pytest.mark.usefixtures("async_environment") async def test_async_mock_transport(): async def hello_world(request): diff --git a/tests/client/test_redirects.py b/tests/client/test_redirects.py index 84d371e9fa..cd5fce4a93 100644 --- a/tests/client/test_redirects.py +++ b/tests/client/test_redirects.py @@ -1,4 +1,3 @@ -import httpcore import pytest import httpx @@ -6,9 +5,7 @@ def redirects(request: httpx.Request) -> httpx.Response: if request.url.scheme not in ("http", "https"): - raise httpcore.UnsupportedProtocol( - f"Scheme {request.url.scheme!r} not supported." - ) + raise httpx.UnsupportedProtocol(f"Scheme {request.url.scheme!r} not supported.") if request.url.path == "/redirect_301": status_code = httpx.codes.MOVED_PERMANENTLY diff --git a/tests/conftest.py b/tests/conftest.py index 12db1b0bb2..62c10c9fb4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -76,8 +76,6 @@ async def app(scope, receive, send): assert scope["type"] == "http" if scope["path"].startswith("/slow_response"): await slow_response(scope, receive, send) - elif scope["path"].startswith("/slow_stream_response"): - await slow_stream_response(scope, receive, send) elif scope["path"].startswith("/status"): await status_code(scope, receive, send) elif scope["path"].startswith("/echo_body"): @@ -113,19 +111,6 @@ async def slow_response(scope, receive, send): await send({"type": "http.response.body", "body": b"Hello, world!"}) -async def slow_stream_response(scope, receive, send): - await send( - { - "type": "http.response.start", - "status": 200, - "headers": [[b"content-type", b"text/plain"]], - } - ) - - await sleep(1) - await send({"type": "http.response.body", "body": b"", "more_body": False}) - - async def status_code(scope, receive, send): status_code = int(scope["path"].replace("/status/", "")) await send( diff --git a/tests/models/test_responses.py b/tests/models/test_responses.py index cb46719c17..793fad3b76 100644 --- a/tests/models/test_responses.py +++ b/tests/models/test_responses.py @@ -733,7 +733,7 @@ def test_json_without_specified_encoding_value_error(): # force incorrect guess from `guess_json_utf` to trigger error with mock.patch("httpx._models.guess_json_utf", return_value="utf-32"): response = httpx.Response(200, content=content, headers=headers) - with pytest.raises(ValueError): + with pytest.raises(json.decoder.JSONDecodeError): response.json() @@ -767,7 +767,7 @@ def test_decode_error_with_request(header_value): headers = [(b"Content-Encoding", header_value)] body = b"test 123" compressed_body = brotli.compress(body)[3:] - with pytest.raises(ValueError): + with pytest.raises(httpx.DecodingError): httpx.Response( 200, headers=headers, @@ -788,7 +788,7 @@ def test_value_error_without_request(header_value): headers = [(b"Content-Encoding", header_value)] body = b"test 123" compressed_body = brotli.compress(body)[3:] - with pytest.raises(ValueError): + with pytest.raises(httpx.DecodingError): httpx.Response(200, headers=headers, content=compressed_body) diff --git a/tests/test_decoders.py b/tests/test_decoders.py index f8c432cc89..faaf71d2fb 100644 --- a/tests/test_decoders.py +++ b/tests/test_decoders.py @@ -170,7 +170,7 @@ def test_decoding_errors(header_value): request = httpx.Request("GET", "https://example.org") httpx.Response(200, headers=headers, content=compressed_body, request=request) - with pytest.raises(ValueError): + with pytest.raises(httpx.DecodingError): httpx.Response(200, headers=headers, content=compressed_body) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 4b136dfcd1..1bc6723a87 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,10 +1,10 @@ -from typing import Any +from unittest import mock import httpcore import pytest import httpx -from httpx._exceptions import HTTPCORE_EXC_MAP +from httpx._transports.default import HTTPCORE_EXC_MAP def test_httpcore_all_exceptions_mapped() -> None: @@ -29,25 +29,40 @@ def test_httpcore_exception_mapping(server) -> None: HTTPCore exception mapping works as expected. """ - # Make sure we don't just map to `NetworkError`. - with pytest.raises(httpx.ConnectError): - httpx.get("http://doesnotexist") + def connect_failed(*args, **kwargs): + raise httpcore.ConnectError() - # Make sure streaming methods also map exceptions. - url = server.url.copy_with(path="/slow_stream_response") - timeout = httpx.Timeout(None, read=0.1) - with httpx.stream("GET", url, timeout=timeout) as stream: - with pytest.raises(httpx.ReadTimeout): - stream.read() + class TimeoutStream: + def __iter__(self): + raise httpcore.ReadTimeout() + + def close(self): + pass + + class CloseFailedStream: + def __iter__(self): + yield b"" - # Make sure it also works with custom transports. - class MockTransport(httpx.BaseTransport): - def handle_request(self, *args: Any, **kwargs: Any) -> Any: - raise httpcore.ProtocolError() + def close(self): + raise httpcore.CloseError() - client = httpx.Client(transport=MockTransport()) - with pytest.raises(httpx.ProtocolError): - client.get("http://testserver") + with mock.patch("httpcore.SyncConnectionPool.request", side_effect=connect_failed): + with pytest.raises(httpx.ConnectError): + httpx.get(server.url) + + with mock.patch( + "httpcore.SyncConnectionPool.request", + return_value=(200, [], TimeoutStream(), {}), + ): + with pytest.raises(httpx.ReadTimeout): + httpx.get(server.url) + + with mock.patch( + "httpcore.SyncConnectionPool.request", + return_value=(200, [], CloseFailedStream(), {}), + ): + with pytest.raises(httpx.CloseError): + httpx.get(server.url) def test_httpx_exceptions_exposed() -> None: @@ -66,3 +81,15 @@ def test_httpx_exceptions_exposed() -> None: if not_exposed: # pragma: nocover pytest.fail(f"Unexposed HTTPX exceptions: {not_exposed}") + + +def test_request_attribute() -> None: + # Exception without request attribute + exc = httpx.ReadTimeout("Read operation timed out") + with pytest.raises(RuntimeError): + exc.request + + # Exception with request attribute + request = httpx.Request("GET", "https://www.example.com") + exc = httpx.ReadTimeout("Read operation timed out", request=request) + assert exc.request == request