From d5430931b925002a9d572b99beeeda69995d9860 Mon Sep 17 00:00:00 2001 From: Kadir Can Ozden <101993364+bysiber@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:58:11 +0200 Subject: [PATCH] Ensure mounted transports are closed even if main transport raises If a client with proxy mounts fails to close the main transport (its close()/__exit__/aclose()/__aexit__ raises), the mounted transports were never reached, leaking their connections. Wrap the main transport cleanup in try/finally so mounts are always closed regardless. Ported from encode/httpx#3769. --- src/httpx2/httpx2/_client.py | 40 ++++++++++++--------- tests/httpx2/client/test_async_client.py | 44 ++++++++++++++++++++++++ tests/httpx2/client/test_client.py | 42 ++++++++++++++++++++++ 3 files changed, 110 insertions(+), 16 deletions(-) diff --git a/src/httpx2/httpx2/_client.py b/src/httpx2/httpx2/_client.py index ae73434e..5a76e1ea 100644 --- a/src/httpx2/httpx2/_client.py +++ b/src/httpx2/httpx2/_client.py @@ -1369,10 +1369,12 @@ def close(self) -> None: if self._state != ClientState.CLOSED: self._state = ClientState.CLOSED - self._transport.close() - for transport in self._mounts.values(): - if transport is not None: - transport.close() + try: + self._transport.close() + finally: + for transport in self._mounts.values(): + if transport is not None: + transport.close() def __enter__(self: T) -> T: if self._state != ClientState.UNOPENED: @@ -1398,10 +1400,12 @@ def __exit__( ) -> None: self._state = ClientState.CLOSED - self._transport.__exit__(exc_type, exc_value, traceback) - for transport in self._mounts.values(): - if transport is not None: - transport.__exit__(exc_type, exc_value, traceback) + try: + self._transport.__exit__(exc_type, exc_value, traceback) + finally: + for transport in self._mounts.values(): + if transport is not None: + transport.__exit__(exc_type, exc_value, traceback) class AsyncClient(BaseClient): @@ -2206,10 +2210,12 @@ async def aclose(self) -> None: if self._state != ClientState.CLOSED: self._state = ClientState.CLOSED - await self._transport.aclose() - for proxy in self._mounts.values(): - if proxy is not None: - await proxy.aclose() + try: + await self._transport.aclose() + finally: + for proxy in self._mounts.values(): + if proxy is not None: + await proxy.aclose() async def __aenter__(self: U) -> U: if self._state != ClientState.UNOPENED: @@ -2235,7 +2241,9 @@ async def __aexit__( ) -> None: self._state = ClientState.CLOSED - await self._transport.__aexit__(exc_type, exc_value, traceback) - for proxy in self._mounts.values(): - if proxy is not None: - await proxy.__aexit__(exc_type, exc_value, traceback) + try: + await self._transport.__aexit__(exc_type, exc_value, traceback) + finally: + for proxy in self._mounts.values(): + if proxy is not None: + await proxy.__aexit__(exc_type, exc_value, traceback) diff --git a/tests/httpx2/client/test_async_client.py b/tests/httpx2/client/test_async_client.py index 710bf020..ebf78dd9 100644 --- a/tests/httpx2/client/test_async_client.py +++ b/tests/httpx2/client/test_async_client.py @@ -258,6 +258,50 @@ async def __aexit__(self, *args: typing.Any) -> None: ] +@pytest.mark.anyio +async def test_aclose_still_closes_mount_when_transport_raises() -> None: + class RaisingTransport(httpx2.AsyncBaseTransport): + async def aclose(self) -> None: + raise RuntimeError("boom") + + class Transport(httpx2.AsyncBaseTransport): + def __init__(self) -> None: + self.closed = False + + async def aclose(self) -> None: + self.closed = True + + mounted = Transport() + client = httpx2.AsyncClient(transport=RaisingTransport(), mounts={"http://www.example.org": mounted}) + + with pytest.raises(RuntimeError): + await client.aclose() + + assert mounted.closed + + +@pytest.mark.anyio +async def test_aexit_still_closes_mount_when_transport_raises() -> None: + class RaisingTransport(httpx2.AsyncBaseTransport): + async def aclose(self) -> None: + raise RuntimeError("boom") + + class Transport(httpx2.AsyncBaseTransport): + def __init__(self) -> None: + self.closed = False + + async def aclose(self) -> None: + self.closed = True + + mounted = Transport() + + with pytest.raises(RuntimeError): + async with httpx2.AsyncClient(transport=RaisingTransport(), mounts={"http://www.example.org": mounted}): + pass + + assert mounted.closed + + def hello_world(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, text="Hello, world!") diff --git a/tests/httpx2/client/test_client.py b/tests/httpx2/client/test_client.py index 6fad1b66..46dc33b1 100644 --- a/tests/httpx2/client/test_client.py +++ b/tests/httpx2/client/test_client.py @@ -308,6 +308,48 @@ def __exit__(self, *args: typing.Any) -> None: ] +def test_close_still_closes_mount_when_transport_raises() -> None: + class RaisingTransport(httpx2.BaseTransport): + def close(self) -> None: + raise RuntimeError("boom") + + class Transport(httpx2.BaseTransport): + def __init__(self) -> None: + self.closed = False + + def close(self) -> None: + self.closed = True + + mounted = Transport() + client = httpx2.Client(transport=RaisingTransport(), mounts={"http://www.example.org": mounted}) + + with pytest.raises(RuntimeError): + client.close() + + assert mounted.closed + + +def test_exit_still_closes_mount_when_transport_raises() -> None: + class RaisingTransport(httpx2.BaseTransport): + def close(self) -> None: + raise RuntimeError("boom") + + class Transport(httpx2.BaseTransport): + def __init__(self) -> None: + self.closed = False + + def close(self) -> None: + self.closed = True + + mounted = Transport() + + with pytest.raises(RuntimeError): + with httpx2.Client(transport=RaisingTransport(), mounts={"http://www.example.org": mounted}): + pass + + assert mounted.closed + + def hello_world(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, text="Hello, world!")