Description
Describe the bug
The issue lies in how aiohttp handles timeouts when using ClientSession and connection pooling. Currently, the timeout starts counting from the moment the coroutine session.get() is called, not from when the connection is acquired from the connection pool. This means that the time spent waiting in the connection pool queue is also included in the timeout calculation, leading to unexpected TimeoutError exceptions even if the actual request could be completed within the specified timeout.
This behavior can cause issues when the connection pool is overloaded with many simultaneous requests, as clients experience timeouts primarily due to delays in acquiring a connection rather than delays in the actual request processing.
To Reproduce
- Use the following server.py and client.py to replicate the issue.
- Run the server first and then execute the client.
- Observe the output and note the high count of
TimeoutError
server.py
import asyncio
from aiohttp import web
async def main(_):
await asyncio.sleep(1)
return web.Response()
app = web.Application()
app.add_routes([web.get('/', main)])
web.run_app(app)
client.py
import asyncio
import time
import statistics as stat
import aiohttp
async def request(session) -> tuple[bool | None, float]:
timeout_error = None
start_time = time.time()
try:
async with session.get('/'):
pass
timeout_error = False
except TimeoutError:
timeout_error = True
finally:
end_time = time.time()
duration = end_time - start_time
return timeout_error, duration
async def main(n: int=1000):
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession('http://127.0.0.1:8080', timeout=timeout) as session:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(request(session)) for _ in range(n)]
return [task.result() for task in tasks]
if __name__ == '__main__':
results: list[tuple[bool | None, float]] = asyncio.run(main())
assert len(results) == len([x for x in results if x[0] is not None])
durations = [x[1] for x in results]
print(f'Requests count: {len(results)}')
print(f'Timeout Error count: {len([x for x in results if x[0] is True])}')
print(f'First request time: {durations[0]}')
print(f'Last request time: {durations[-1]}')
print(f'Median request time: {stat.median(durations)}')
Run a server as follows:
python server.py
Then run a client:
python client.py
Despite setting the timeout to 5 seconds, many requests fail with TimeoutError.
The observed behavior indicates that the timeout starts before acquiring a connection from the pool, rather than only including the time for the actual request.
Expected behavior
All requests will be executed successfully without a TimeoutError.
The timeout should start counting only after a connection is acquired from the connection pool. This ensures that the specified timeout duration applies exclusively to the actual request-processing time, making the timeout behavior predictable and intuitive.
Logs/tracebacks
$ python client.py
Requests count: 1000
Timeout Error count: 500
First request time: 1.070129156112671
Last request time: 5.747848749160767
Median request time: 5.41119110584259
Python Version
3.13.0
aiohttp Version
3.11.11
multidict Version
6.1.0
propcache Version
0.2.1
yarl Version
1.18.3
OS
Debian GNU/Linux 11 (bullseye)
Related component
Client
Additional context
No response
Code of Conduct
- I agree to follow the aio-libs Code of Conduct