Repository: aio-libs/aiohttp

Issue:
_RequestContextManager will not close connection if ClientConnectorError has happened
## Long story short

When using _RequestContextManager and try to get a host which can not be connected to, connection will not be released to the pool.

## Expected behaviour

Raises an Exception and connection releases.

## Actual behaviour

Raises an Exception.

## Steps to reproduce

```
import asyncio

import aiohttp


async def get_image(session, url):
    async with session.get(url) as response:
        if response.status != 200:
            print('Something went wrong!')
            return None

        return await response.read()


async def worker():
    conn = aiohttp.TCPConnector(limit=1)
    async with aiohttp.ClientSession(connector=conn) as session:
        await asyncio.gather(
            asyncio.ensure_future(get_image(session, 'http://unavailable:9090/test.jpg')),
            asyncio.ensure_future(get_image(session, 'http://unavailable:9090/test.jpg'))
        )


if __name__ == '__main__':
    loop = asyncio.get_event_loop()

    loop.run_until_complete(worker())
    loop.close()
```

## Your environment

macOS 10.12.6
Python 3.6.1
aiohttp 2.2.5



Discussion:
I think I recently hit the same issue and believe I found the root cause, which resides in the BaseConnector class (in connect.py)

Below is the way I currently understand the issue:
Let us assume we have two connection requests coming in as is the case in the above repro.

The first connection passes the limit, and goes into the _create_connection method
The second connection, does not pass the limit, and so gets appended to the list of waiters, waiting to be notified to start up again.
Normally this notification happens when a previous Connection object gets created and released (in the release of close call).

However if the previous _create_connection raises, no connection object gets created. The self._acquired gets cleaned up correctly in this case, but noone notifies that a new connection is now available, and so the waiter never gets freed.

This has the observable outcome that a waiter will always be stuck waiting, and that your connection pool size is effectively reduced by one.

Hopefully this is helpful in fixing the bug.
@matthiasblondeel great explanation! Do you have any ideas how to fix that?
I will look into this problem during this weekend

Resolving patch:
diff --git a/aiohttp/connector.py b/aiohttp/connector.py
--- a/aiohttp/connector.py
+++ b/aiohttp/connector.py
@@ -375,10 +375,13 @@ def connect(self, req):
             # This connection will now count towards the limit.
             waiters = self._waiters[key]
             waiters.append(fut)
-            yield from fut
-            waiters.remove(fut)
-            if not waiters:
-                del self._waiters[key]
+            try:
+                yield from fut
+            finally:
+                # remove a waiter even if it was cancelled
+                waiters.remove(fut)
+                if not waiters:
+                    del self._waiters[key]
 
         proto = self._get(key)
         if proto is None:
@@ -390,6 +393,13 @@ def connect(self, req):
                 if self._closed:
                     proto.close()
                     raise ClientConnectionError("Connector is closed.")
+            except:
+                # signal to waiter
+                for waiter in self._waiters[key]:
+                    if not waiter.done():
+                        waiter.set_result(None)
+                        break
+                raise
             finally:
                 if not self._closed:
                     self._acquired.remove(placeholder)


Test patch:
diff --git a/tests/test_connector.py b/tests/test_connector.py
--- a/tests/test_connector.py
+++ b/tests/test_connector.py
@@ -1173,6 +1173,100 @@ def test_force_close_and_explicit_keep_alive(loop):
     assert conn
 
 
+@asyncio.coroutine
+def test_error_on_connection(loop):
+    conn = aiohttp.BaseConnector(limit=1, loop=loop)
+
+    req = mock.Mock()
+    req.connection_key = 'key'
+    proto = mock.Mock()
+    i = 0
+
+    fut = helpers.create_future(loop=loop)
+    exc = OSError()
+
+    @asyncio.coroutine
+    def create_connection(req):
+        nonlocal i
+        i += 1
+        if i == 1:
+            yield from fut
+            raise exc
+        elif i == 2:
+            return proto
+
+    conn._create_connection = create_connection
+
+    t1 = helpers.ensure_future(conn.connect(req), loop=loop)
+    t2 = helpers.ensure_future(conn.connect(req), loop=loop)
+    yield from asyncio.sleep(0, loop=loop)
+    assert not t1.done()
+    assert not t2.done()
+    assert len(conn._acquired_per_host['key']) == 1
+
+    fut.set_result(None)
+    with pytest.raises(OSError):
+        yield from t1
+
+    ret = yield from t2
+    assert len(conn._acquired_per_host['key']) == 1
+
+    assert ret._key == 'key'
+    assert ret.protocol == proto
+    assert proto in conn._acquired
+
+
+@asyncio.coroutine
+def test_error_on_connection_with_cancelled_waiter(loop):
+    conn = aiohttp.BaseConnector(limit=1, loop=loop)
+
+    req = mock.Mock()
+    req.connection_key = 'key'
+    proto = mock.Mock()
+    i = 0
+
+    fut1 = helpers.create_future(loop=loop)
+    fut2 = helpers.create_future(loop=loop)
+    exc = OSError()
+
+    @asyncio.coroutine
+    def create_connection(req):
+        nonlocal i
+        i += 1
+        if i == 1:
+            yield from fut1
+            raise exc
+        if i == 2:
+            yield from fut2
+        elif i == 3:
+            return proto
+
+    conn._create_connection = create_connection
+
+    t1 = helpers.ensure_future(conn.connect(req), loop=loop)
+    t2 = helpers.ensure_future(conn.connect(req), loop=loop)
+    t3 = helpers.ensure_future(conn.connect(req), loop=loop)
+    yield from asyncio.sleep(0, loop=loop)
+    assert not t1.done()
+    assert not t2.done()
+    assert len(conn._acquired_per_host['key']) == 1
+
+    fut1.set_result(None)
+    fut2.cancel()
+    with pytest.raises(OSError):
+        yield from t1
+
+    with pytest.raises(asyncio.CancelledError):
+        yield from t2
+
+    ret = yield from t3
+    assert len(conn._acquired_per_host['key']) == 1
+
+    assert ret._key == 'key'
+    assert ret.protocol == proto
+    assert proto in conn._acquired
+
+
 @asyncio.coroutine
 def test_tcp_connector(test_client, loop):
     @asyncio.coroutine