Repository: aio-libs/aiohttp

Issue:
ClientTimeout values of 0 don't disable timeouts
🐞 **Describe the bug**
`ClientTimeout` values of 0 and `None` are treated differently. The docs [here](https://docs.aiohttp.org/en/stable/client_quickstart.html#timeouts) state:

> All fields are floats, None or 0 disables a particular timeout check

and versions 3.6.3 and earlier do work this way. After #5091 this behavior was changed.

💡 **To Reproduce**
This simple example illustrates the issue:

```python
timeout = aiohttp.ClientTimeout(
    total=90,
    connect=0,
    sock_connect=0,
    sock_read=0
)

async with aiohttp.ClientSession(timeout=timeout) as session:
    print(await session.get("https://google.com"))
```

With v3.6.3:

```bash
ryan@debian:~$ python -m pip show aiohttp
Name: aiohttp
Version: 3.6.3

ryan@debian:~$ python test.py
<ClientResponse(https://www.google.com/) [200 OK]>
```

With v3.7.4:

```bash
ryan@debian:~$ python -m pip show aiohttp
Name: aiohttp
Version: 3.7.4

ryan@debian:~$ python test.py
```

```python
Traceback (most recent call last):
  File "/home/ryan/.virtualenvs/aio-test/lib/python3.9/site-packages/aiohttp/client.py", line 520, in _request
    conn = await self._connector.connect(
  File "/home/ryan/.virtualenvs/aio-test/lib/python3.9/site-packages/aiohttp/connector.py", line 535, in connect
    proto = await self._create_connection(req, traces, timeout)
  File "/home/ryan/.virtualenvs/aio-test/lib/python3.9/site-packages/aiohttp/connector.py", line 892, in _create_connection
    _, proto = await self._create_direct_connection(req, traces, timeout)
  File "/home/ryan/.virtualenvs/aio-test/lib/python3.9/site-packages/aiohttp/connector.py", line 999, in _create_direct_connection
    hosts = await asyncio.shield(host_resolved)
asyncio.exceptions.CancelledError

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/ryan/.virtualenvs/aio-test/lib/python3.9/site-packages/aiohttp/client.py", line 520, in _request
    conn = await self._connector.connect(
  File "/home/ryan/.virtualenvs/aio-test/lib/python3.9/site-packages/async_timeout/__init__.py", line 45, in __exit__
    self._do_exit(exc_type)
  File "/home/ryan/.virtualenvs/aio-test/lib/python3.9/site-packages/async_timeout/__init__.py", line 92, in _do_exit
    raise asyncio.TimeoutError
asyncio.exceptions.TimeoutError

...

File "/home/ryan/.virtualenvs/aio-test/lib/python3.9/site-packages/aiohttp/client.py", line 524, in _request
    raise ServerTimeoutError(
aiohttp.client_exceptions.ServerTimeoutError: Connection timeout to host https://google.com
```
This timeout happens immediately.

With 3.7.4 if I change the timeout values from 0 to `None` it works correctly.

```
ryan@debian:~$ python -m pip show aiohttp
Name: aiohttp
Version: 3.7.4

ryan@debian:~$ python test.py
<ClientResponse(https://www.google.com/) [200 OK]>
```

💡 **Expected behavior**
Timeouts with values of 0 should be ignored (or the docs updated to say 0 doesn't disable the timeouts).



Discussion:
The change you reference doesn't appear to ever have been backported, so that change is not in aiohttp <4.
I was trying different versions to narrow the issue down and saw it in the list of commits here https://github.com/aio-libs/aiohttp/compare/v3.6.3...v3.7.0b0 and in the [changelog ](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst#370-2020-10-24). The behavior of the timeouts definitely changed between 3.6.3 and 3.7.0b0. When I removed the check [here](https://github.com/aio-libs/aiohttp/blob/v3.7.4/aiohttp/helpers.py#L678) 3.7.x versions behaved the same as 3.6.3 in my test case.
Ah nevermind, I see now. The commit you mentioned is: https://github.com/aio-libs/aiohttp/commit/5996f85b84cfeeed3a8b0c93f1530e6fb973fe1f

But, the PR you linked to was for master, so shows different code. That was confusing me.
But, that check only stops it from calling `ceil()`. Surely, removing that would only make your code work by luck, in other words only if the connection is able to complete before the current second is up (i.e. `ceil(current_time)`).

There must be some other cause...
Or, maybe 3.6 only works by luck as well...
> But, that check only stops it from calling `ceil()`. Surely, removing that would only make your code work by luck, in other words only if the connection is able to complete before the current second is up (i.e. `ceil(current_time)`).
> 
> There must be some other cause...

I considered that and tried testing this earlier today. With the server below which waits 5 seconds before returning a request 3.6 works and 3.7 fails. My logic here is that if the rounding really meant that my 0s timeouts were actually something between 0 and 1 second instead of being disabled then a request which took 5 seconds should timeout after ~1s. With 3.6.3 I don't see that happening.

`server.py`
```python
from aiohttp import web
import asyncio

routes = web.RouteTableDef()

@routes.get("/")
async def get(request):
    await asyncio.sleep(5)
    return web.json_response({"key":"value"})

if __name__ == "__main__":
    app = web.Application()
    app.add_routes(routes)
    web.run_app(app)
```

and using this client:

```python
timeout = aiohttp.ClientTimeout(
    total=0,
    connect=0,
    sock_connect=0,
    sock_read=0
)

async with aiohttp.ClientSession(timeout=timeout) as session:
    print(await session.get("http://localhost:8080"))
```

With 3.6.3
```bash
ryan@debian:~$ time python test.py
<ClientResponse(http://localhost:8080) [200 OK]>
<CIMultiDictProxy('Content-Type': 'application/json; charset=utf-8', 'Content-Length': '16', 'Date': 'Tue, 09 Mar 2021 19:59:13 GMT', 'Server': 'Python/3.9 aiohttp/3.6.3')>


real    0m5.183s
user    0m0.160s
sys     0m0.021s
```

and if I change any of the timeouts to a non-zero value it does what I expect. With `sock_read=2`:

```bash
ryan@debian:~$ time python test.py
Traceback (most recent call last):
...
aiohttp.client_exceptions.ServerTimeoutError: Timeout on reading data from socket

real    0m2.183s
user    0m0.162s
sys     0m0.020s
```
Thanks, so if I try with that server, then I see it fails with the unmodified 3.7 code. However, if I change the line to add 0.1 seconds:
`now = loop.time() + 0.1`

Then it works fine. So, it seems like the timeout is not being respected, as long as it has opened the connection before the timeout, or something along those lines...
OK, I think I've found the issue. The sock_connect will always do this timeout. So, it was working by luck as we initially thought in the old code. But, it is only opening the connection that is affected, which is why your test server doesn't reproduce it.

Resolving patch:
diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py
--- a/aiohttp/helpers.py
+++ b/aiohttp/helpers.py
@@ -671,15 +671,15 @@ def timeout(self) -> None:
 
 
 def ceil_timeout(delay: Optional[float]) -> async_timeout.Timeout:
-    if delay is None:
+    if delay is None or delay <= 0:
         return async_timeout.timeout(None)
-    else:
-        loop = get_running_loop()
-        now = loop.time()
-        when = now + delay
-        if delay > 5:
-            when = ceil(when)
-        return async_timeout.timeout_at(when)
+
+    loop = get_running_loop()
+    now = loop.time()
+    when = now + delay
+    if delay > 5:
+        when = ceil(when)
+    return async_timeout.timeout_at(when)
 
 
 class HeadersMixin:


Test patch:
diff --git a/tests/test_client_session.py b/tests/test_client_session.py
--- a/tests/test_client_session.py
+++ b/tests/test_client_session.py
@@ -712,6 +712,15 @@ async def test_client_session_timeout_argument() -> None:
     assert session.timeout == 500
 
 
+async def test_client_session_timeout_zero() -> None:
+    timeout = client.ClientTimeout(total=10, connect=0, sock_connect=0, sock_read=0)
+    try:
+        async with ClientSession(timeout=timeout) as session:
+            await session.get("http://example.com")
+    except asyncio.TimeoutError:
+        pytest.fail("0 should disable timeout.")
+
+
 async def test_requote_redirect_url_default() -> None:
     session = ClientSession()
     assert session.requote_redirect_url