From 97fd1c6e59ef0a15eb8321f3021e227e61d9311a Mon Sep 17 00:00:00 2001 From: tianrking <10758833+tianrking@users.noreply.github.com> Date: Fri, 17 Jul 2026 04:16:59 +0800 Subject: [PATCH 1/4] Reclaim orphaned pool connections after cancellation --- httpcore/_async/connection_pool.py | 6 +++ httpcore/_sync/connection_pool.py | 6 +++ tests/_async/test_connection_pool.py | 72 ++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/httpcore/_async/connection_pool.py b/httpcore/_async/connection_pool.py index 5ef74e64..48a0140a 100644 --- a/httpcore/_async/connection_pool.py +++ b/httpcore/_async/connection_pool.py @@ -297,6 +297,12 @@ def _assign_requests_to_connections(self) -> list[AsyncConnectionInterface]: # log: "closing idle connection" self._connections.remove(connection) closing_connections.append(connection) + elif not connection.is_idle() and not any( + request.connection is connection for request in self._requests + ): + # log: "closing orphaned connection" + self._connections.remove(connection) + closing_connections.append(connection) # Assign queued requests to connections. queued_requests = [request for request in self._requests if request.is_queued()] diff --git a/httpcore/_sync/connection_pool.py b/httpcore/_sync/connection_pool.py index 4b26f9c6..928f42d9 100644 --- a/httpcore/_sync/connection_pool.py +++ b/httpcore/_sync/connection_pool.py @@ -297,6 +297,12 @@ def _assign_requests_to_connections(self) -> list[ConnectionInterface]: # log: "closing idle connection" self._connections.remove(connection) closing_connections.append(connection) + elif not connection.is_idle() and not any( + request.connection is connection for request in self._requests + ): + # log: "closing orphaned connection" + self._connections.remove(connection) + closing_connections.append(connection) # Assign queued requests to connections. queued_requests = [request for request in self._requests if request.is_queued()] diff --git a/tests/_async/test_connection_pool.py b/tests/_async/test_connection_pool.py index bc4b251e..f1bb5bde 100644 --- a/tests/_async/test_connection_pool.py +++ b/tests/_async/test_connection_pool.py @@ -1,12 +1,15 @@ import logging import typing +import anyio import hpack import hyperframe.frame import pytest import trio as concurrency import httpcore +from httpcore._async import connection_pool +from httpcore._async.interfaces import AsyncConnectionInterface @pytest.mark.anyio @@ -451,6 +454,75 @@ async def trace(name, kwargs): ] +@pytest.mark.anyio +async def test_connection_pool_removes_connection_after_request_cancellation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + request_assigned = anyio.Event() + + class WaitingPoolRequest(connection_pool.AsyncPoolRequest): + async def wait_for_connection( + self, timeout: float | None = None + ) -> AsyncConnectionInterface: + await super().wait_for_connection(timeout) + request_assigned.set() + await anyio.sleep_forever() + raise AssertionError("Pool request must be cancelled") + + monkeypatch.setattr(connection_pool, "AsyncPoolRequest", WaitingPoolRequest) + + class ConnectingConnection(AsyncConnectionInterface): + def __init__(self) -> None: + self.closed = False + + async def handle_async_request( + self, request: httpcore.Request + ) -> httpcore.Response: + raise AssertionError("Cancelled request must not reach the connection") + + async def aclose(self) -> None: + self.closed = True + + def can_handle_request(self, origin: httpcore.Origin) -> bool: + return True + + def is_available(self) -> bool: + return False + + def has_expired(self) -> bool: + return False + + def is_idle(self) -> bool: + return False + + def is_closed(self) -> bool: + return False + + def info(self) -> str: + return "CONNECTING" + + class TestPool(httpcore.AsyncConnectionPool): + def create_connection( + self, origin: httpcore.Origin + ) -> AsyncConnectionInterface: + return connection + + connection = ConnectingConnection() + async with TestPool(max_connections=1) as pool: + + async def request() -> None: + with pytest.raises(TimeoutError): + with anyio.fail_after(0.01): + await pool.request("GET", "https://example.com/") + + async with anyio.create_task_group() as task_group: + task_group.start_soon(request) + await request_assigned.wait() + + assert pool.connections == [] + assert connection.closed + + @pytest.mark.anyio async def test_connection_pool_with_immediate_expiry(): """ From 5597ce4dbb794653fa9c3d57c1b293402d862e6f Mon Sep 17 00:00:00 2001 From: tianrking <10758833+tianrking@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:01:20 +0800 Subject: [PATCH 2/4] test: keep cancellation regression out of unasync inputs --- tests/_async/test_connection_pool.py | 72 ---------------------------- tests/test_cancellations.py | 71 +++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 72 deletions(-) diff --git a/tests/_async/test_connection_pool.py b/tests/_async/test_connection_pool.py index f1bb5bde..bc4b251e 100644 --- a/tests/_async/test_connection_pool.py +++ b/tests/_async/test_connection_pool.py @@ -1,15 +1,12 @@ import logging import typing -import anyio import hpack import hyperframe.frame import pytest import trio as concurrency import httpcore -from httpcore._async import connection_pool -from httpcore._async.interfaces import AsyncConnectionInterface @pytest.mark.anyio @@ -454,75 +451,6 @@ async def trace(name, kwargs): ] -@pytest.mark.anyio -async def test_connection_pool_removes_connection_after_request_cancellation( - monkeypatch: pytest.MonkeyPatch, -) -> None: - request_assigned = anyio.Event() - - class WaitingPoolRequest(connection_pool.AsyncPoolRequest): - async def wait_for_connection( - self, timeout: float | None = None - ) -> AsyncConnectionInterface: - await super().wait_for_connection(timeout) - request_assigned.set() - await anyio.sleep_forever() - raise AssertionError("Pool request must be cancelled") - - monkeypatch.setattr(connection_pool, "AsyncPoolRequest", WaitingPoolRequest) - - class ConnectingConnection(AsyncConnectionInterface): - def __init__(self) -> None: - self.closed = False - - async def handle_async_request( - self, request: httpcore.Request - ) -> httpcore.Response: - raise AssertionError("Cancelled request must not reach the connection") - - async def aclose(self) -> None: - self.closed = True - - def can_handle_request(self, origin: httpcore.Origin) -> bool: - return True - - def is_available(self) -> bool: - return False - - def has_expired(self) -> bool: - return False - - def is_idle(self) -> bool: - return False - - def is_closed(self) -> bool: - return False - - def info(self) -> str: - return "CONNECTING" - - class TestPool(httpcore.AsyncConnectionPool): - def create_connection( - self, origin: httpcore.Origin - ) -> AsyncConnectionInterface: - return connection - - connection = ConnectingConnection() - async with TestPool(max_connections=1) as pool: - - async def request() -> None: - with pytest.raises(TimeoutError): - with anyio.fail_after(0.01): - await pool.request("GET", "https://example.com/") - - async with anyio.create_task_group() as task_group: - task_group.start_soon(request) - await request_assigned.wait() - - assert pool.connections == [] - assert connection.closed - - @pytest.mark.anyio async def test_connection_pool_with_immediate_expiry(): """ diff --git a/tests/test_cancellations.py b/tests/test_cancellations.py index 033acef6..7d51214d 100644 --- a/tests/test_cancellations.py +++ b/tests/test_cancellations.py @@ -6,6 +6,8 @@ import pytest import httpcore +from httpcore._async import connection_pool +from httpcore._async.interfaces import AsyncConnectionInterface class SlowWriteStream(httpcore.AsyncNetworkStream): @@ -95,6 +97,75 @@ async def connect_tcp( return SlowReadStream(self._buffer) +@pytest.mark.anyio +async def test_connection_pool_removes_connection_after_request_cancellation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + request_assigned = anyio.Event() + + class WaitingPoolRequest(connection_pool.AsyncPoolRequest): + async def wait_for_connection( + self, timeout: float | None = None + ) -> AsyncConnectionInterface: + await super().wait_for_connection(timeout) + request_assigned.set() + await anyio.sleep_forever() + raise AssertionError("Pool request must be cancelled") + + monkeypatch.setattr(connection_pool, "AsyncPoolRequest", WaitingPoolRequest) + + class ConnectingConnection(AsyncConnectionInterface): + def __init__(self) -> None: + self.closed = False + + async def handle_async_request( + self, request: httpcore.Request + ) -> httpcore.Response: + raise AssertionError("Cancelled request must not reach the connection") + + async def aclose(self) -> None: + self.closed = True + + def can_handle_request(self, origin: httpcore.Origin) -> bool: + return True + + def is_available(self) -> bool: + return False + + def has_expired(self) -> bool: + return False + + def is_idle(self) -> bool: + return False + + def is_closed(self) -> bool: + return False + + def info(self) -> str: + return "CONNECTING" + + class TestPool(httpcore.AsyncConnectionPool): + def create_connection( + self, origin: httpcore.Origin + ) -> AsyncConnectionInterface: + return connection + + connection = ConnectingConnection() + async with TestPool(max_connections=1) as pool: + + async def request() -> None: + with pytest.raises(TimeoutError): + with anyio.fail_after(0.01): + await pool.request("GET", "https://example.com/") + + async with anyio.create_task_group() as task_group: + task_group.start_soon(request) + await request_assigned.wait() + + assert pool.connections == [] + assert connection.closed + + @pytest.mark.anyio async def test_connection_pool_timeout_during_request(): """ From fa94911d437a13de718246ea3b9199d9b31334e9 Mon Sep 17 00:00:00 2001 From: tianrking <10758833+tianrking@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:07:02 +0800 Subject: [PATCH 3/4] fix: retain Python 3.8 test compatibility --- tests/test_cancellations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_cancellations.py b/tests/test_cancellations.py index 7d51214d..d7d751a4 100644 --- a/tests/test_cancellations.py +++ b/tests/test_cancellations.py @@ -105,7 +105,7 @@ async def test_connection_pool_removes_connection_after_request_cancellation( class WaitingPoolRequest(connection_pool.AsyncPoolRequest): async def wait_for_connection( - self, timeout: float | None = None + self, timeout: typing.Optional[float] = None ) -> AsyncConnectionInterface: await super().wait_for_connection(timeout) request_assigned.set() From 3d9073f624b1a2950541e3f3f1769790852da97a Mon Sep 17 00:00:00 2001 From: tianrking <10758833+tianrking@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:16:30 +0800 Subject: [PATCH 4/4] test: exclude unreachable cancellation guards from coverage --- tests/test_cancellations.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/test_cancellations.py b/tests/test_cancellations.py index d7d751a4..58eac242 100644 --- a/tests/test_cancellations.py +++ b/tests/test_cancellations.py @@ -110,7 +110,7 @@ async def wait_for_connection( await super().wait_for_connection(timeout) request_assigned.set() await anyio.sleep_forever() - raise AssertionError("Pool request must be cancelled") + raise AssertionError("Pool request must be cancelled") # pragma: nocover monkeypatch.setattr(connection_pool, "AsyncPoolRequest", WaitingPoolRequest) @@ -121,16 +121,18 @@ def __init__(self) -> None: async def handle_async_request( self, request: httpcore.Request ) -> httpcore.Response: - raise AssertionError("Cancelled request must not reach the connection") + raise AssertionError( # pragma: nocover + "Cancelled request must not reach the connection" + ) async def aclose(self) -> None: self.closed = True def can_handle_request(self, origin: httpcore.Origin) -> bool: - return True + return True # pragma: nocover def is_available(self) -> bool: - return False + return False # pragma: nocover def has_expired(self) -> bool: return False @@ -142,7 +144,7 @@ def is_closed(self) -> bool: return False def info(self) -> str: - return "CONNECTING" + return "CONNECTING" # pragma: nocover class TestPool(httpcore.AsyncConnectionPool): def create_connection(