From 44f44d8fc6d4b4149ca7ca8773aee342d4bbefbd Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 25 Aug 2026 14:52:21 +0200 Subject: [PATCH 1/4] Include 'timed out' message in async timeout exceptions --- src/httpcore2/httpcore2/_exceptions.py | 2 ++ tests/httpx2/test_timeouts.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/httpcore2/httpcore2/_exceptions.py b/src/httpcore2/httpcore2/_exceptions.py index a54d43b7..ef577635 100644 --- a/src/httpcore2/httpcore2/_exceptions.py +++ b/src/httpcore2/httpcore2/_exceptions.py @@ -14,6 +14,8 @@ def map_exceptions(map: ExceptionMapping) -> Generator[None]: except Exception as exc: # noqa: PIE786 for from_exc, to_exc in map.items(): if isinstance(exc, from_exc): + if issubclass(to_exc, TimeoutException) and not str(exc): + raise to_exc("timed out") from exc raise to_exc(exc) from exc raise # pragma: no cover diff --git a/tests/httpx2/test_timeouts.py b/tests/httpx2/test_timeouts.py index 6ce30e2b..06dba6da 100644 --- a/tests/httpx2/test_timeouts.py +++ b/tests/httpx2/test_timeouts.py @@ -15,7 +15,7 @@ async def test_read_timeout(server: TestServer) -> None: timeout = httpx2.Timeout(None, read=1e-6) async with httpx2.AsyncClient(timeout=timeout) as client: - with pytest.raises(httpx2.ReadTimeout): + with pytest.raises(httpx2.ReadTimeout, match="timed out"): await client.get(server.url.copy_with(path="/slow_response")) From 0fdf3a11e6f55879615637daa6fc1cac66cf9432 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 4 Sep 2026 15:21:16 +0200 Subject: [PATCH 2/4] Handle read timeout messages in async backends --- src/httpcore2/httpcore2/_backends/anyio.py | 16 +++++++++------- src/httpcore2/httpcore2/_backends/trio.py | 12 +++++++----- src/httpcore2/httpcore2/_exceptions.py | 2 -- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/httpcore2/httpcore2/_backends/anyio.py b/src/httpcore2/httpcore2/_backends/anyio.py index 16c270e8..6061b21b 100644 --- a/src/httpcore2/httpcore2/_backends/anyio.py +++ b/src/httpcore2/httpcore2/_backends/anyio.py @@ -26,17 +26,19 @@ def __init__(self, stream: anyio.abc.ByteStream) -> None: async def read(self, max_bytes: int, timeout: float | None = None) -> bytes: exc_map: dict[type[Exception], type[Exception]] = { - TimeoutError: ReadTimeout, anyio.BrokenResourceError: ReadError, anyio.ClosedResourceError: ReadError, anyio.EndOfStream: ReadError, } - with map_exceptions(exc_map): - with anyio.fail_after(timeout): - try: - return await self._stream.receive(max_bytes=max_bytes) - except anyio.EndOfStream: # pragma: no cover - return b"" + try: + with map_exceptions(exc_map): + with anyio.fail_after(timeout): + try: + return await self._stream.receive(max_bytes=max_bytes) + except anyio.EndOfStream: # pragma: no cover + return b"" + except TimeoutError as exc: + raise ReadTimeout("timed out") from exc async def write(self, buffer: bytes, timeout: float | None = None) -> None: if not buffer: diff --git a/src/httpcore2/httpcore2/_backends/trio.py b/src/httpcore2/httpcore2/_backends/trio.py index 742985b9..50086a4d 100644 --- a/src/httpcore2/httpcore2/_backends/trio.py +++ b/src/httpcore2/httpcore2/_backends/trio.py @@ -25,14 +25,16 @@ def __init__(self, stream: trio.abc.Stream) -> None: async def read(self, max_bytes: int, timeout: float | None = None) -> bytes: timeout_or_inf = float("inf") if timeout is None else timeout exc_map: ExceptionMapping = { - trio.TooSlowError: ReadTimeout, trio.BrokenResourceError: ReadError, trio.ClosedResourceError: ReadError, } - with map_exceptions(exc_map): - with trio.fail_after(timeout_or_inf): - data: bytes = await self._stream.receive_some(max_bytes=max_bytes) - return data + try: + with map_exceptions(exc_map): + with trio.fail_after(timeout_or_inf): + data: bytes = await self._stream.receive_some(max_bytes=max_bytes) + return data + except trio.TooSlowError as exc: + raise ReadTimeout("timed out") from exc async def write(self, buffer: bytes, timeout: float | None = None) -> None: if not buffer: diff --git a/src/httpcore2/httpcore2/_exceptions.py b/src/httpcore2/httpcore2/_exceptions.py index ef577635..a54d43b7 100644 --- a/src/httpcore2/httpcore2/_exceptions.py +++ b/src/httpcore2/httpcore2/_exceptions.py @@ -14,8 +14,6 @@ def map_exceptions(map: ExceptionMapping) -> Generator[None]: except Exception as exc: # noqa: PIE786 for from_exc, to_exc in map.items(): if isinstance(exc, from_exc): - if issubclass(to_exc, TimeoutException) and not str(exc): - raise to_exc("timed out") from exc raise to_exc(exc) from exc raise # pragma: no cover From f062146901322ee7d5cc3a61436f24ea1fa60b52 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 4 Sep 2026 15:33:07 +0200 Subject: [PATCH 3/4] Apply timeout messages in async network backends --- src/httpcore2/httpcore2/_backends/anyio.py | 35 +++++++++++----------- src/httpcore2/httpcore2/_backends/trio.py | 20 ++++++------- src/httpcore2/httpcore2/_exceptions.py | 2 +- tests/httpx2/test_timeouts.py | 4 +-- 4 files changed, 29 insertions(+), 32 deletions(-) diff --git a/src/httpcore2/httpcore2/_backends/anyio.py b/src/httpcore2/httpcore2/_backends/anyio.py index 6061b21b..3c9ad318 100644 --- a/src/httpcore2/httpcore2/_backends/anyio.py +++ b/src/httpcore2/httpcore2/_backends/anyio.py @@ -10,6 +10,7 @@ from .._exceptions import ( ConnectError, ConnectTimeout, + ExceptionMapping, ReadError, ReadTimeout, WriteError, @@ -25,27 +26,25 @@ def __init__(self, stream: anyio.abc.ByteStream) -> None: self._stream = stream async def read(self, max_bytes: int, timeout: float | None = None) -> bytes: - exc_map: dict[type[Exception], type[Exception]] = { + exc_map: ExceptionMapping = { + TimeoutError: lambda exc: ReadTimeout(str(exc) or "timed out"), anyio.BrokenResourceError: ReadError, anyio.ClosedResourceError: ReadError, anyio.EndOfStream: ReadError, } - try: - with map_exceptions(exc_map): - with anyio.fail_after(timeout): - try: - return await self._stream.receive(max_bytes=max_bytes) - except anyio.EndOfStream: # pragma: no cover - return b"" - except TimeoutError as exc: - raise ReadTimeout("timed out") from exc + with map_exceptions(exc_map): + with anyio.fail_after(timeout): + try: + return await self._stream.receive(max_bytes=max_bytes) + except anyio.EndOfStream: # pragma: no cover + return b"" async def write(self, buffer: bytes, timeout: float | None = None) -> None: if not buffer: return - exc_map: dict[type[Exception], type[Exception]] = { - TimeoutError: WriteTimeout, + exc_map: ExceptionMapping = { + TimeoutError: lambda exc: WriteTimeout(str(exc) or "timed out"), anyio.BrokenResourceError: WriteError, anyio.ClosedResourceError: WriteError, } @@ -62,8 +61,8 @@ async def start_tls( server_hostname: str | None = None, timeout: float | None = None, ) -> AsyncNetworkStream: - exc_map: dict[type[Exception], type[Exception]] = { - TimeoutError: ConnectTimeout, + exc_map: ExceptionMapping = { + TimeoutError: lambda exc: ConnectTimeout(str(exc) or "timed out"), anyio.BrokenResourceError: ConnectError, anyio.EndOfStream: ConnectError, ssl.SSLError: ConnectError, @@ -109,8 +108,8 @@ async def connect_tcp( ) -> AsyncNetworkStream: # pragma: no cover if socket_options is None: socket_options = [] - exc_map: dict[type[Exception], type[Exception]] = { - TimeoutError: ConnectTimeout, + exc_map: ExceptionMapping = { + TimeoutError: lambda exc: ConnectTimeout(str(exc) or "timed out"), OSError: ConnectError, anyio.BrokenResourceError: ConnectError, } @@ -134,8 +133,8 @@ async def connect_unix_socket( ) -> AsyncNetworkStream: # pragma: no cover if socket_options is None: socket_options = [] - exc_map: dict[type[Exception], type[Exception]] = { - TimeoutError: ConnectTimeout, + exc_map: ExceptionMapping = { + TimeoutError: lambda exc: ConnectTimeout(str(exc) or "timed out"), OSError: ConnectError, anyio.BrokenResourceError: ConnectError, } diff --git a/src/httpcore2/httpcore2/_backends/trio.py b/src/httpcore2/httpcore2/_backends/trio.py index 50086a4d..e945fe30 100644 --- a/src/httpcore2/httpcore2/_backends/trio.py +++ b/src/httpcore2/httpcore2/_backends/trio.py @@ -25,16 +25,14 @@ def __init__(self, stream: trio.abc.Stream) -> None: async def read(self, max_bytes: int, timeout: float | None = None) -> bytes: timeout_or_inf = float("inf") if timeout is None else timeout exc_map: ExceptionMapping = { + trio.TooSlowError: lambda exc: ReadTimeout(str(exc) or "timed out"), trio.BrokenResourceError: ReadError, trio.ClosedResourceError: ReadError, } - try: - with map_exceptions(exc_map): - with trio.fail_after(timeout_or_inf): - data: bytes = await self._stream.receive_some(max_bytes=max_bytes) - return data - except trio.TooSlowError as exc: - raise ReadTimeout("timed out") from exc + with map_exceptions(exc_map): + with trio.fail_after(timeout_or_inf): + data: bytes = await self._stream.receive_some(max_bytes=max_bytes) + return data async def write(self, buffer: bytes, timeout: float | None = None) -> None: if not buffer: @@ -42,7 +40,7 @@ async def write(self, buffer: bytes, timeout: float | None = None) -> None: timeout_or_inf = float("inf") if timeout is None else timeout exc_map: ExceptionMapping = { - trio.TooSlowError: WriteTimeout, + trio.TooSlowError: lambda exc: WriteTimeout(str(exc) or "timed out"), trio.BrokenResourceError: WriteError, trio.ClosedResourceError: WriteError, } @@ -61,7 +59,7 @@ async def start_tls( ) -> AsyncNetworkStream: timeout_or_inf = float("inf") if timeout is None else timeout exc_map: ExceptionMapping = { - trio.TooSlowError: ConnectTimeout, + trio.TooSlowError: lambda exc: ConnectTimeout(str(exc) or "timed out"), trio.BrokenResourceError: ConnectError, } ssl_stream = trio.SSLStream( @@ -123,7 +121,7 @@ async def connect_tcp( socket_options = [] # pragma: no cover timeout_or_inf = float("inf") if timeout is None else timeout exc_map: ExceptionMapping = { - trio.TooSlowError: ConnectTimeout, + trio.TooSlowError: lambda exc: ConnectTimeout(str(exc) or "timed out"), trio.BrokenResourceError: ConnectError, OSError: ConnectError, } @@ -144,7 +142,7 @@ async def connect_unix_socket( socket_options = [] timeout_or_inf = float("inf") if timeout is None else timeout exc_map: ExceptionMapping = { - trio.TooSlowError: ConnectTimeout, + trio.TooSlowError: lambda exc: ConnectTimeout(str(exc) or "timed out"), trio.BrokenResourceError: ConnectError, OSError: ConnectError, } diff --git a/src/httpcore2/httpcore2/_exceptions.py b/src/httpcore2/httpcore2/_exceptions.py index a54d43b7..d3150b38 100644 --- a/src/httpcore2/httpcore2/_exceptions.py +++ b/src/httpcore2/httpcore2/_exceptions.py @@ -4,7 +4,7 @@ import typing from collections.abc import Generator -ExceptionMapping = typing.Mapping[type[Exception], type[Exception]] +ExceptionMapping = typing.Mapping[type[Exception], typing.Callable[[Exception], Exception]] @contextlib.contextmanager diff --git a/tests/httpx2/test_timeouts.py b/tests/httpx2/test_timeouts.py index 06dba6da..60bef164 100644 --- a/tests/httpx2/test_timeouts.py +++ b/tests/httpx2/test_timeouts.py @@ -24,7 +24,7 @@ async def test_write_timeout(server: TestServer) -> None: timeout = httpx2.Timeout(None, write=1e-6) async with httpx2.AsyncClient(timeout=timeout) as client: - with pytest.raises(httpx2.WriteTimeout): + with pytest.raises(httpx2.WriteTimeout, match="timed out"): data = b"*" * 1024 * 1024 * 100 await client.put(server.url.copy_with(path="/slow_response"), content=data) @@ -35,7 +35,7 @@ async def test_connect_timeout(server: TestServer) -> None: timeout = httpx2.Timeout(None, connect=1e-6) async with httpx2.AsyncClient(timeout=timeout) as client: - with pytest.raises(httpx2.ConnectTimeout): + with pytest.raises(httpx2.ConnectTimeout, match="timed out"): # See https://stackoverflow.com/questions/100841/ await client.get("http://10.255.255.1/") From 0bea70c0b40ee04d489773bfa627f0b11226c3c9 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 4 Sep 2026 15:51:00 +0200 Subject: [PATCH 4/4] Centralize async timeout exception creation --- src/httpcore2/httpcore2/_backends/anyio.py | 11 ++++++----- src/httpcore2/httpcore2/_backends/trio.py | 11 ++++++----- src/httpcore2/httpcore2/_exceptions.py | 7 +++++++ 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/httpcore2/httpcore2/_backends/anyio.py b/src/httpcore2/httpcore2/_backends/anyio.py index 3c9ad318..20f825ee 100644 --- a/src/httpcore2/httpcore2/_backends/anyio.py +++ b/src/httpcore2/httpcore2/_backends/anyio.py @@ -16,6 +16,7 @@ WriteError, WriteTimeout, map_exceptions, + map_timeout, ) from .._utils import is_socket_readable from .base import SOCKET_OPTION, AsyncNetworkBackend, AsyncNetworkStream @@ -27,7 +28,7 @@ def __init__(self, stream: anyio.abc.ByteStream) -> None: async def read(self, max_bytes: int, timeout: float | None = None) -> bytes: exc_map: ExceptionMapping = { - TimeoutError: lambda exc: ReadTimeout(str(exc) or "timed out"), + TimeoutError: map_timeout(ReadTimeout), anyio.BrokenResourceError: ReadError, anyio.ClosedResourceError: ReadError, anyio.EndOfStream: ReadError, @@ -44,7 +45,7 @@ async def write(self, buffer: bytes, timeout: float | None = None) -> None: return exc_map: ExceptionMapping = { - TimeoutError: lambda exc: WriteTimeout(str(exc) or "timed out"), + TimeoutError: map_timeout(WriteTimeout), anyio.BrokenResourceError: WriteError, anyio.ClosedResourceError: WriteError, } @@ -62,7 +63,7 @@ async def start_tls( timeout: float | None = None, ) -> AsyncNetworkStream: exc_map: ExceptionMapping = { - TimeoutError: lambda exc: ConnectTimeout(str(exc) or "timed out"), + TimeoutError: map_timeout(ConnectTimeout), anyio.BrokenResourceError: ConnectError, anyio.EndOfStream: ConnectError, ssl.SSLError: ConnectError, @@ -109,7 +110,7 @@ async def connect_tcp( if socket_options is None: socket_options = [] exc_map: ExceptionMapping = { - TimeoutError: lambda exc: ConnectTimeout(str(exc) or "timed out"), + TimeoutError: map_timeout(ConnectTimeout), OSError: ConnectError, anyio.BrokenResourceError: ConnectError, } @@ -134,7 +135,7 @@ async def connect_unix_socket( if socket_options is None: socket_options = [] exc_map: ExceptionMapping = { - TimeoutError: lambda exc: ConnectTimeout(str(exc) or "timed out"), + TimeoutError: map_timeout(ConnectTimeout), OSError: ConnectError, anyio.BrokenResourceError: ConnectError, } diff --git a/src/httpcore2/httpcore2/_backends/trio.py b/src/httpcore2/httpcore2/_backends/trio.py index e945fe30..1e9ebe90 100644 --- a/src/httpcore2/httpcore2/_backends/trio.py +++ b/src/httpcore2/httpcore2/_backends/trio.py @@ -14,6 +14,7 @@ WriteError, WriteTimeout, map_exceptions, + map_timeout, ) from .base import SOCKET_OPTION, AsyncNetworkBackend, AsyncNetworkStream @@ -25,7 +26,7 @@ def __init__(self, stream: trio.abc.Stream) -> None: async def read(self, max_bytes: int, timeout: float | None = None) -> bytes: timeout_or_inf = float("inf") if timeout is None else timeout exc_map: ExceptionMapping = { - trio.TooSlowError: lambda exc: ReadTimeout(str(exc) or "timed out"), + trio.TooSlowError: map_timeout(ReadTimeout), trio.BrokenResourceError: ReadError, trio.ClosedResourceError: ReadError, } @@ -40,7 +41,7 @@ async def write(self, buffer: bytes, timeout: float | None = None) -> None: timeout_or_inf = float("inf") if timeout is None else timeout exc_map: ExceptionMapping = { - trio.TooSlowError: lambda exc: WriteTimeout(str(exc) or "timed out"), + trio.TooSlowError: map_timeout(WriteTimeout), trio.BrokenResourceError: WriteError, trio.ClosedResourceError: WriteError, } @@ -59,7 +60,7 @@ async def start_tls( ) -> AsyncNetworkStream: timeout_or_inf = float("inf") if timeout is None else timeout exc_map: ExceptionMapping = { - trio.TooSlowError: lambda exc: ConnectTimeout(str(exc) or "timed out"), + trio.TooSlowError: map_timeout(ConnectTimeout), trio.BrokenResourceError: ConnectError, } ssl_stream = trio.SSLStream( @@ -121,7 +122,7 @@ async def connect_tcp( socket_options = [] # pragma: no cover timeout_or_inf = float("inf") if timeout is None else timeout exc_map: ExceptionMapping = { - trio.TooSlowError: lambda exc: ConnectTimeout(str(exc) or "timed out"), + trio.TooSlowError: map_timeout(ConnectTimeout), trio.BrokenResourceError: ConnectError, OSError: ConnectError, } @@ -142,7 +143,7 @@ async def connect_unix_socket( socket_options = [] timeout_or_inf = float("inf") if timeout is None else timeout exc_map: ExceptionMapping = { - trio.TooSlowError: lambda exc: ConnectTimeout(str(exc) or "timed out"), + trio.TooSlowError: map_timeout(ConnectTimeout), trio.BrokenResourceError: ConnectError, OSError: ConnectError, } diff --git a/src/httpcore2/httpcore2/_exceptions.py b/src/httpcore2/httpcore2/_exceptions.py index d3150b38..61d58133 100644 --- a/src/httpcore2/httpcore2/_exceptions.py +++ b/src/httpcore2/httpcore2/_exceptions.py @@ -82,3 +82,10 @@ class ReadError(NetworkError): class WriteError(NetworkError): pass + + +def map_timeout(exception_class: type[TimeoutException]) -> typing.Callable[[Exception], Exception]: + def factory(exc: Exception) -> Exception: + return exception_class(str(exc) or "timed out") + + return factory