Skip to content

Client timeout includes connection pool queue wait time #10313

Open
@mikev9

Description

@mikev9

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions