Repository: encode/httpx

Issue:
Keepalive connections aren't released when closing the ConnectionPool
Hello. I am having an issue where it looks like connections aren't being closed correctly, and after i reach a number of requests equivalent to "hard_limit" of pool_limits, i get a PoolTimeout exception.

I tried upgrading to httpx==0.10.1, with no success.

Minimal example:
```
import httpx, asyncio, logging
from httpx import PoolLimits
from random import randint

queue = asyncio.Queue()

clients = [
	httpx.AsyncClient(
		http2=True,
		pool_limits=PoolLimits(soft_limit=2, hard_limit=10),
		cookies={'a': '123456789', 'b': '987654321'},
	)
]

async def worker_loop(cid, client, queue):
	while 1:
		sub_id = await queue.get()

		async with client as c:
			r = await c.get(f'https://mywebsite.dummy/submission.php?id={sub_id}')

		if r.status_code != 200:
			print(cid, f'Got status code {r.status_code} while parsing {sub_id}')
			return

async def main():
	for i in range(2500):
		await queue.put(randint(1, 80000000))

	for k, v in enumerate(clients):
		asyncio.create_task(worker_loop(k, v, queue))

	while 1:
		if queue.qsize() == 0:
			await queue.put(randint(1, 80000000))
		await asyncio.sleep(2)

loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.stop()
```


I checked with netstat, and only one actual connection is opened to the IP address, so pooling seems to work fine.
I really cannot understand why. I even tried using the "aclose()" syntax, without the "async with" block, but no difference at all.


Discussion:
> only one actual connection is opened to the IP address, so pooling seems to work fine

I don't think that's actually the behavior you'd expect (though, from your code that's the behavior one should expect): since requests are concurrent here, HTTPX will use as many connections as it can to perform the requests, i.e. up to 10 (the hard limit). Once one of the 10 first requests terminates, the connection can be reused by a subsequent request.

So what you'd expect is to have 10 connections almost constantly being used, but you only get one, plus a pool timeout…

Your code is similar in spirit to this:

```python
while True:
    url = await get_next_url()
    async with client:
        response = await client.get(url)
```

The `async with client` is what causes the connection pool to be setup, and then teardown. So in practice, you are tearing down the connection pool after each request, meaning you're only ever able to use one connection (because any previous connection would have been thrown away by closing the pool after the previous request terminated).

What you need to do for the connection pool to be effectively used, is to move the `async with` block at the top-level:

```python
async with client:
    while True:
        url = await get_next_url()
        response = await client.get(url)
```

With this I am able to run your program without issues against a local dummy server.

But that doesn't explain the pool timeout…

---

I am able to reproduce the timeout issue with this example:

```python
import asyncio

import httpx


async def main() -> None:
    url = "http://localhost:8000"
    max_connections = 2
    client = httpx.AsyncClient(
        pool_limits=httpx.PoolLimits(soft_limit=1, hard_limit=max_connections)
    )

    for _ in range(max_connections + 1):
        async with client:
            await client.get(url)


if __name__ == "__main__":
    asyncio.run(main())
```

(Note that if we switch out `for _ in range()` and `async with client` as suggested above, there's no error.)

I pinpointed the origin of the issue to us not releasing the internal `max_connections` semaphore when closing keepalive connections when closing the connection pool. PR incoming!

Resolving patch:
diff --git a/httpx/dispatch/connection_pool.py b/httpx/dispatch/connection_pool.py
--- a/httpx/dispatch/connection_pool.py
+++ b/httpx/dispatch/connection_pool.py
@@ -206,6 +206,7 @@ async def close(self) -> None:
         connections = list(self.keepalive_connections)
         self.keepalive_connections.clear()
         for connection in connections:
+            self.max_connections.release()
             await connection.close()
 
     def pop_connection(self, origin: Origin) -> typing.Optional[HTTPConnection]:


Test patch:
diff --git a/tests/dispatch/test_connection_pools.py b/tests/dispatch/test_connection_pools.py
--- a/tests/dispatch/test_connection_pools.py
+++ b/tests/dispatch/test_connection_pools.py
@@ -221,3 +221,28 @@ async def test_connection_closed_free_semaphore_on_acquire(server, restart):
 
         response = await http.request("GET", server.url)
         assert response.status_code == 200
+
+
+@pytest.mark.usefixtures("async_environment")
+async def test_connection_pool_closed_close_keepalive_and_free_semaphore(server):
+    """
+    Closing the connection pool should close remaining keepalive connections and
+    release the max_connections semaphore.
+    """
+    http = ConnectionPool(pool_limits=httpx.PoolLimits(hard_limit=1))
+
+    async with http:
+        response = await http.request("GET", server.url)
+        await response.aread()
+        assert response.status_code == 200
+        assert len(http.keepalive_connections) == 1
+
+    assert len(http.keepalive_connections) == 0
+
+    # Perform a second round of requests to make sure the max_connections semaphore
+    # was released properly.
+
+    async with http:
+        response = await http.request("GET", server.url)
+        await response.aread()
+        assert response.status_code == 200