Repository: aio-libs/aiohttp

Issue:
Request is not cancelled on timeout, its response is returned for the next request
### Describe the bug

I see this strange behaviour in version 3.9.0 when I iterate to retry timed out request. It started with version 3.9.0b1.
Version 3.9.0b0 works as expected. 

The probelm is when the initial request times out it should get cancelled, and indeed that iteration catches TimeoutError. The next iteration, which retries the request with new parameters somehow returns the response of the inittial cancelled request...

Please see the minimal reproducing example.

### To Reproduce

```python
import asyncio

import aiohttp


async def minimum_error():
    url = "http://httpbin.org/delay/10"
    timeout = aiohttp.ClientTimeout(total=7)
    async with aiohttp.ClientSession(timeout=timeout) as session:
        for i in range(2):
            print(f"iteration {i} start")
            try:
                async with session.get(url, params={"iteration": i}) as resp:
                    print("ok", resp.status)
                    print(await resp.text())
            except TimeoutError:
                print("aiohttp timeout")
                await asyncio.sleep(1)
                continue
            except asyncio.CancelledError:
                print("aiohttp cancelled???")
                raise
            finally:
                print(f"iteration {i} done")


asyncio.run(minimum_error())
```

### Expected behavior

Correct results with 3.9.0b0
```
iteration 0 start
aiohttp timeout
iteration 0 done
iteration 1 start
aiohttp timeout
iteration 1 done
```

### Logs/tracebacks

```python-traceback
Actual results with 3.9.0:

iteration 0 start
aiohttp timeout
iteration 0 done
iteration 1 start
ok 200
{
  "args": {
    "iteration": "0"
  }, 
  "data": "", 
  "files": {}, 
  "form": {}, 
  "headers": {
    "Accept": "*/*", 
    "Accept-Encoding": "gzip, deflate, br", 
    "Host": "httpbin.org", 
    "User-Agent": "Python/3.12 aiohttp/3.9.0"
  }, 
  "url": "http://httpbin.org/delay/10?iteration=0"
}

iteration 1 done
```


### Python Version

```console
$ python --version
Python 3.12.0
```


### aiohttp Version

```console
$ python -m pip show aiohttp
Name: aiohttp
Version: 3.9.0
Summary: Async http client/server framework (asyncio)
Home-page: https://github.com/aio-libs/aiohttp
Author: 
Author-email: 
License: Apache 2
Location: /home/andy/.local/lib/python3.12/site-packages
Requires: aiosignal, attrs, frozenlist, multidict, yarl
Required-by: web3
```


### multidict Version

```console
$ python -m pip show multidict
Name: multidict
Version: 6.0.4
Summary: multidict implementation
Home-page: https://github.com/aio-libs/multidict
Author: Andrew Svetlov
Author-email: andrew.svetlov@gmail.com
License: Apache 2
Location: /home/andy/.local/lib/python3.12/site-packages
Requires: 
Required-by: aiohttp, yarl
```


### yarl Version

```console
$ python -m pip show yarl
Name: yarl
Version: 1.9.3
Summary: Yet another URL library
Home-page: https://github.com/aio-libs/yarl
Author: Andrew Svetlov
Author-email: andrew.svetlov@gmail.com
License: Apache-2.0
Location: /home/andy/.local/lib/python3.12/site-packages
Requires: idna, multidict
Required-by: aiohttp, gql
```


### OS

Ubuntu Linux

### Related component

Client

### Additional context

_No response_

### Code of Conduct

- [X] I agree to follow the aio-libs Code of Conduct


Discussion:
Hello, I can confirm we've hit the same issue in Rally (Elasticsearch benchmarking tool). https://github.com/elastic/rally/pull/1804
This might be a side effect of using the callback done running with a call soon
I see similar issues in our application. The following code exposes a problem on my machine (Python 3.11):
```python
import asyncio
import random
from typing import Any

import aiohttp


async def main() -> None:
    # Usually works when this is `<= 100`
    num_calls = 120

    client = aiohttp.ClientSession()

    async def call(n: int) -> Any:
        # This service echoes the request body in the response
        # The random timeout is key here, it works when `timeout=None`
        response = await client.put(
            "http://localhost:3000/", timeout=random.random(), json={"n": n}
        )
        body = await response.text()
        return (n, body)

    results = await asyncio.gather(
        *[call(i) for i in range(num_calls)], return_exceptions=True
    )

    for result in results:
        if isinstance(result, Exception):
            print(type(result))
        else:
            print(result)

    await client.close()


asyncio.run(main())
```

On version 3.8.6 this does what you would expect with the n in the request and in the response matching:
```
(95, '{"n":95}')
<class 'TimeoutError'>
<class 'TimeoutError'>
<class 'TimeoutError'>
(99, '{"n":99}')
(100, '{"n":100}')
<class 'TimeoutError'>
(102, '{"n":102}')
<class 'TimeoutError'>
(104, '{"n":104}')
(105, '{"n":105}')
<class 'TimeoutError'>
(107, '{"n":107}')
<class 'TimeoutError'>
<class 'TimeoutError'>
(110, '{"n":110}')
<class 'TimeoutError'>
<class 'TimeoutError'>
(113, '{"n":113}')
(114, '{"n":114}')
(115, '{"n":115}')
(116, '{"n":116}')
(117, '{"n":117}')
<class 'TimeoutError'>
<class 'TimeoutError'>
```

However, on 3.9.0, we see this:
(Seemingly recycling responses after 100 when at least one timed out)
```
(98, '{"n":98}')
<class 'TimeoutError'>
<class 'TimeoutError'>
<class 'TimeoutError'>
<class 'TimeoutError'>
(103, '{"n":60}')
(104, '{"n":58}')
(105, '{"n":71}')
<class 'TimeoutError'>
<class 'TimeoutError'>
(108, '{"n":77}')
(109, '{"n":100}')
<class 'TimeoutError'>
<class 'TimeoutError'>
<class 'TimeoutError'>
(113, '{"n":26}')
<class 'TimeoutError'>
(115, '{"n":2}')
<class 'TimeoutError'>
(117, '{"n":25}')
(118, '{"n":31}')
<class 'TimeoutError'>
```

We also saw other unexplainable behaviour that could be caused by this, but I have no evidence nor re-production of that.

Does anyone have a working test case we can merge as an xfail test so we can validate a fix?

bisected this to https://github.com/aio-libs/aiohttp/pull/7764
> bisected this to #7764

Yeah, I already assumed it would be related to that. Something to do with us previously closing the connection and now we're trying to reuse it.

Resolving patch:
diff --git a/aiohttp/client_reqrep.py b/aiohttp/client_reqrep.py
--- a/aiohttp/client_reqrep.py
+++ b/aiohttp/client_reqrep.py
@@ -967,7 +967,9 @@ def close(self) -> None:
             return
 
         self._cleanup_writer()
-        self._release_connection()
+        if self._connection is not None:
+            self._connection.close()
+            self._connection = None
 
     def release(self) -> Any:
         if not self._released:


Test patch:
diff --git a/tests/test_client_functional.py b/tests/test_client_functional.py
--- a/tests/test_client_functional.py
+++ b/tests/test_client_functional.py
@@ -20,6 +20,7 @@
 from aiohttp import Fingerprint, ServerFingerprintMismatch, hdrs, web
 from aiohttp.abc import AbstractResolver
 from aiohttp.client_exceptions import TooManyRedirects
+from aiohttp.pytest_plugin import AiohttpClient, TestClient
 from aiohttp.test_utils import unused_port
 
 
@@ -3175,6 +3176,39 @@ async def handler(request):
         await client.get("/")
 
 
+async def test_read_timeout_closes_connection(aiohttp_client: AiohttpClient) -> None:
+    request_count = 0
+
+    async def handler(request):
+        nonlocal request_count
+        request_count += 1
+        if request_count < 3:
+            await asyncio.sleep(0.5)
+        return web.Response(body=f"request:{request_count}")
+
+    app = web.Application()
+    app.add_routes([web.get("/", handler)])
+
+    timeout = aiohttp.ClientTimeout(total=0.1)
+    client: TestClient = await aiohttp_client(app, timeout=timeout)
+    with pytest.raises(asyncio.TimeoutError):
+        await client.get("/")
+
+    # Make sure its really closed
+    assert not client.session.connector._conns
+
+    with pytest.raises(asyncio.TimeoutError):
+        await client.get("/")
+
+    # Make sure its really closed
+    assert not client.session.connector._conns
+    result = await client.get("/")
+    assert await result.read() == b"request:3"
+
+    # Make sure its not closed
+    assert client.session.connector._conns
+
+
 async def test_read_timeout_on_prepared_response(aiohttp_client: Any) -> None:
     async def handler(request):
         resp = aiohttp.web.StreamResponse()