Skip to content

fix: clean up garbage connections on cancellation in AsyncConnectionPool#983

Open
mbeijen wants to merge 1 commit into
pydantic:mainfrom
mbeijen:fix-pool-cancellation-cleanup
Open

fix: clean up garbage connections on cancellation in AsyncConnectionPool#983
mbeijen wants to merge 1 commit into
pydantic:mainfrom
mbeijen:fix-pool-cancellation-cleanup

Conversation

@mbeijen
Copy link
Copy Markdown
Contributor

@mbeijen mbeijen commented May 23, 2026

Summary

  • Fixes the AsyncConnectionPool poisoning bug reported in `httpcore.AsyncConnectionPool` poisoning due to `asyncio.CancelledError` #982: an asyncio.CancelledError (or any cancel scope timeout) during connection establishment leaves a NEW-state connection in self._connections that is neither closed nor connected, eventually exhausting max_connections and surfacing as PoolTimeout on every subsequent request.
  • Adds is_connected() to (Async)ConnectionInterface, overridden on the concrete connection classes and proxy wrappers so a connection in the pre-TCP NEW state reports False.
  • Teaches _assign_requests_to_connections() to recognise such garbage connections (not connected AND no live pool request references them) and silently drop them from the pool.

Ported from internal commit https://codeberg.org/httpxyz/httpcorexyz/commit/a7500e4 on the httpcorexyz fork

Reproduction (from #982)

import asyncio
import httpcore2 as httpcore

async def main():
    async with httpcore.AsyncConnectionPool(max_connections=1) as pool:
        tasks = [asyncio.create_task(pool.request("GET", f"http://localhost:8888/{i}")) for i in range(2)]
        await asyncio.sleep(0)
        for task in tasks:
            task.cancel()
        await asyncio.gather(*tasks, return_exceptions=True)

        print(pool)
        # Before: <AsyncConnectionPool [Requests: 0 active, 0 queued | Connections: 1 active, 0 idle]>
        # After:  <AsyncConnectionPool [Requests: 0 active, 0 queued | Connections: 0 active, 0 idle]>
        await pool.request("GET", "http://localhost:8888/", extensions={"timeout": {"pool": 1}})
        # Before: httpcore2.PoolTimeout
        # After:  proceeds normally (or surfaces a genuine ConnectError/ConnectTimeout)

Test plan

  • scripts/check — ruff format/check + mypy clean
  • scripts/test — full suite passes (1651 passed, 1 skipped) on both asyncio and trio backends
  • scripts/coverage — 100% coverage maintained
  • New test_connection_pool_cancellation_during_waiting_for_connection verifies the pool is empty after cancellation in the connection-wait phase, on both asyncio and trio
  • Manually re-ran the reproduction from `httpcore.AsyncConnectionPool` poisoning due to `asyncio.CancelledError` #982 against the patched pool — pool stays empty and the follow-up request no longer hangs on a PoolTimeout

Closes #982.

🤖 Generated with Claude Code

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented May 23, 2026

Merging this PR will not alter performance

✅ 15 untouched benchmarks
⏩ 7 skipped benchmarks1


Comparing mbeijen:fix-pool-cancellation-cleanup (8bfb25c) with main (c2a2b8d)

Open in CodSpeed

Footnotes

  1. 7 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

Adds `is_connected()` to `AsyncConnectionInterface` / `ConnectionInterface`,
overridden by `AsyncHTTPConnection` / `AsyncSocks5Connection` to return
`False` while the TCP handshake is still in progress (i.e. while
`self._connection is None`).

The connection pool's `_assign_requests_to_connections()` now uses
`is_connected()` (together with the set of connections currently
referenced by an active pool request) to detect *garbage* connections —
NEW-state connections whose request was cancelled before the TCP
handshake completed.  Such connections are neither closed nor idle, so
the previous code silently leaked them into `self._connections`,
eventually exhausting `max_connections` and causing `PoolTimeout` on
every subsequent request to that pool.  They are now silently dropped
from the pool (there is no socket yet to close).

A new test `test_connection_pool_cancellation_during_waiting_for_connection`
patches `AsyncPoolRequest.wait_for_connection` to never return, then
cancels the request via `anyio.move_on_after` and asserts the pool is
empty afterwards.

Addresses pydantic#982.

Ported from httpcorexyz commit a7500e4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mbeijen mbeijen force-pushed the fix-pool-cancellation-cleanup branch from 8e7fa6f to 8bfb25c Compare May 24, 2026 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant