Repository: encode/httpcore

Issue:
If start_tls raises exception connection is retained in pool as connecting
I was investigating https://github.com/encode/httpcore/pull/117 since i'm hit with that. But since code has been very much refactored, it doesn't make sense anymore. However something similar may remain.

The following test fails for example. I don't think the stream should remain in pool if start_tls raises.

```
@pytest.mark.anyio
@unittest.mock.patch("httpcore.backends.mock.AsyncMockStream.start_tls")
async def test_connection_pool_with_exception_on_start(start_tls_mock: unittest.mock.MagicMock):
    """
    HTTP/1.1 requests that result in an exception should not be returned to the
    connection pool.
    """
    start_tls_mock.side_effect = Exception()
    network_backend = AsyncMockBackend([b"Wait, this isn't valid HTTP!"])

    async with AsyncConnectionPool(network_backend=network_backend) as pool:
        # Sending an initial request, which once complete will not return to the pool.
        with pytest.raises(Exception):
            await pool.request(
                "GET", "https://example.com/"
            )

        info = [repr(c) for c in pool.connections]
        assert info == []
```

Discussion:

Patch:
diff --git a/httpcore/_async/connection.py b/httpcore/_async/connection.py
index c86d4ef..2860da9 100644
--- a/httpcore/_async/connection.py
+++ b/httpcore/_async/connection.py
@@ -52,6 +52,7 @@ class AsyncHTTPConnection(AsyncConnectionInterface):
             AutoBackend() if network_backend is None else network_backend
         )
         self._connection: Optional[AsyncConnectionInterface] = None
+        self._connect_failed: bool = False
         self._request_lock = AsyncLock()
 
     async def handle_async_request(self, request: Request) -> Response:
@@ -62,27 +63,31 @@ class AsyncHTTPConnection(AsyncConnectionInterface):
 
         async with self._request_lock:
             if self._connection is None:
-                stream = await self._connect(request)
-
-                ssl_object = stream.get_extra_info("ssl_object")
-                http2_negotiated = (
-                    ssl_object is not None
-                    and ssl_object.selected_alpn_protocol() == "h2"
-                )
-                if http2_negotiated or (self._http2 and not self._http1):
-                    from .http2 import AsyncHTTP2Connection
-
-                    self._connection = AsyncHTTP2Connection(
-                        origin=self._origin,
-                        stream=stream,
-                        keepalive_expiry=self._keepalive_expiry,
-                    )
-                else:
-                    self._connection = AsyncHTTP11Connection(
-                        origin=self._origin,
-                        stream=stream,
-                        keepalive_expiry=self._keepalive_expiry,
+                try:
+                    stream = await self._connect(request)
+
+                    ssl_object = stream.get_extra_info("ssl_object")
+                    http2_negotiated = (
+                        ssl_object is not None
+                        and ssl_object.selected_alpn_protocol() == "h2"
                     )
+                    if http2_negotiated or (self._http2 and not self._http1):
+                        from .http2 import AsyncHTTP2Connection
+
+                        self._connection = AsyncHTTP2Connection(
+                            origin=self._origin,
+                            stream=stream,
+                            keepalive_expiry=self._keepalive_expiry,
+                        )
+                    else:
+                        self._connection = AsyncHTTP11Connection(
+                            origin=self._origin,
+                            stream=stream,
+                            keepalive_expiry=self._keepalive_expiry,
+                        )
+                except Exception as exc:
+                    self._connect_failed = True
+                    raise exc
             elif not self._connection.is_available():
                 raise ConnectionNotAvailable()
 
@@ -154,27 +159,31 @@ class AsyncHTTPConnection(AsyncConnectionInterface):
             # If HTTP/2 support is enabled, and the resulting connection could
             # end up as HTTP/2 then we should indicate the connection as being
             # available to service multiple requests.
-            return self._http2 and (self._origin.scheme == b"https" or not self._http1)
+            return (
+                self._http2
+                and (self._origin.scheme == b"https" or not self._http1)
+                and not self._connect_failed
+            )
         return self._connection.is_available()
 
     def has_expired(self) -> bool:
         if self._connection is None:
-            return False
+            return self._connect_failed
         return self._connection.has_expired()
 
     def is_idle(self) -> bool:
         if self._connection is None:
-            return False
+            return self._connect_failed
         return self._connection.is_idle()
 
     def is_closed(self) -> bool:
         if self._connection is None:
-            return False
+            return self._connect_failed
         return self._connection.is_closed()
 
     def info(self) -> str:
         if self._connection is None:
-            return "CONNECTING"
+            return "CONNECTION FAILED" if self._connect_failed else "CONNECTING"
         return self._connection.info()
 
     def __repr__(self) -> str:
diff --git a/httpcore/_sync/connection.py b/httpcore/_sync/connection.py
index bd62885..1b2a222 100644
--- a/httpcore/_sync/connection.py
+++ b/httpcore/_sync/connection.py
@@ -52,6 +52,7 @@ class HTTPConnection(ConnectionInterface):
             SyncBackend() if network_backend is None else network_backend
         )
         self._connection: Optional[ConnectionInterface] = None
+        self._connect_failed: bool = False
         self._request_lock = Lock()
 
     def handle_request(self, request: Request) -> Response:
@@ -62,27 +63,31 @@ class HTTPConnection(ConnectionInterface):
 
         with self._request_lock:
             if self._connection is None:
-                stream = self._connect(request)
-
-                ssl_object = stream.get_extra_info("ssl_object")
-                http2_negotiated = (
-                    ssl_object is not None
-                    and ssl_object.selected_alpn_protocol() == "h2"
-                )
-                if http2_negotiated or (self._http2 and not self._http1):
-                    from .http2 import HTTP2Connection
-
-                    self._connection = HTTP2Connection(
-                        origin=self._origin,
-                        stream=stream,
-                        keepalive_expiry=self._keepalive_expiry,
-                    )
-                else:
-                    self._connection = HTTP11Connection(
-                        origin=self._origin,
-                        stream=stream,
-                        keepalive_expiry=self._keepalive_expiry,
+                try:
+                    stream = self._connect(request)
+
+                    ssl_object = stream.get_extra_info("ssl_object")
+                    http2_negotiated = (
+                        ssl_object is not None
+                        and ssl_object.selected_alpn_protocol() == "h2"
                     )
+                    if http2_negotiated or (self._http2 and not self._http1):
+                        from .http2 import HTTP2Connection
+
+                        self._connection = HTTP2Connection(
+                            origin=self._origin,
+                            stream=stream,
+                            keepalive_expiry=self._keepalive_expiry,
+                        )
+                    else:
+                        self._connection = HTTP11Connection(
+                            origin=self._origin,
+                            stream=stream,
+                            keepalive_expiry=self._keepalive_expiry,
+                        )
+                except Exception as exc:
+                    self._connect_failed = True
+                    raise exc
             elif not self._connection.is_available():
                 raise ConnectionNotAvailable()
 
@@ -154,27 +159,31 @@ class HTTPConnection(ConnectionInterface):
             # If HTTP/2 support is enabled, and the resulting connection could
             # end up as HTTP/2 then we should indicate the connection as being
             # available to service multiple requests.
-            return self._http2 and (self._origin.scheme == b"https" or not self._http1)
+            return (
+                self._http2
+                and (self._origin.scheme == b"https" or not self._http1)
+                and not self._connect_failed
+            )
         return self._connection.is_available()
 
     def has_expired(self) -> bool:
         if self._connection is None:
-            return False
+            return self._connect_failed
         return self._connection.has_expired()
 
     def is_idle(self) -> bool:
         if self._connection is None:
-            return False
+            return self._connect_failed
         return self._connection.is_idle()
 
     def is_closed(self) -> bool:
         if self._connection is None:
-            return False
+            return self._connect_failed
         return self._connection.is_closed()
 
     def info(self) -> str:
         if self._connection is None:
-            return "CONNECTING"
+            return "CONNECTION FAILED" if self._connect_failed else "CONNECTING"
         return self._connection.info()
 
     def __repr__(self) -> str:


Test patch:
diff --git a/tests/_async/test_connection_pool.py b/tests/_async/test_connection_pool.py
index ca4091c..61ae699 100644
--- a/tests/_async/test_connection_pool.py
+++ b/tests/_async/test_connection_pool.py
@@ -3,7 +3,7 @@ from typing import List
 import pytest
 import trio as concurrency
 
-from httpcore import AsyncConnectionPool, UnsupportedProtocol
+from httpcore import AsyncConnectionPool, ConnectError, UnsupportedProtocol
 from httpcore.backends.mock import AsyncMockBackend
 
 
@@ -154,10 +154,10 @@ async def test_trace_request():
 
 
 @pytest.mark.anyio
-async def test_connection_pool_with_exception():
+async def test_connection_pool_with_http_exception():
     """
-    HTTP/1.1 requests that result in an exception should not be returned to the
-    connection pool.
+    HTTP/1.1 requests that result in an exception during the connection should
+    not be returned to the connection pool.
     """
     network_backend = AsyncMockBackend([b"Wait, this isn't valid HTTP!"])
 
@@ -192,6 +192,42 @@ async def test_connection_pool_with_exception():
     ]
 
 
+@pytest.mark.anyio
+async def test_connection_pool_with_connect_exception():
+    """
+    HTTP/1.1 requests that result in an exception during connection should not
+    be returned to the connection pool.
+    """
+
+    class FailedConnectBackend(AsyncMockBackend):
+        async def connect_tcp(
+            self, host: str, port: int, timeout: float = None, local_address: str = None
+        ):
+            raise ConnectError("Could not connect")
+
+    network_backend = FailedConnectBackend([])
+
+    called = []
+
+    async def trace(name, kwargs):
+        called.append(name)
+
+    async with AsyncConnectionPool(network_backend=network_backend) as pool:
+        # Sending an initial request, which once complete will not return to the pool.
+        with pytest.raises(Exception):
+            await pool.request(
+                "GET", "https://example.com/", extensions={"trace": trace}
+            )
+
+        info = [repr(c) for c in pool.connections]
+        assert info == []
+
+    assert called == [
+        "connection.connect_tcp.started",
+        "connection.connect_tcp.failed",
+    ]
+
+
 @pytest.mark.anyio
 async def test_connection_pool_with_immediate_expiry():
     """
diff --git a/tests/_sync/test_connection_pool.py b/tests/_sync/test_connection_pool.py
index 0d2e595..1ed5166 100644
--- a/tests/_sync/test_connection_pool.py
+++ b/tests/_sync/test_connection_pool.py
@@ -3,7 +3,7 @@ from typing import List
 import pytest
 from tests import concurrency
 
-from httpcore import ConnectionPool, UnsupportedProtocol
+from httpcore import ConnectionPool, ConnectError, UnsupportedProtocol
 from httpcore.backends.mock import MockBackend
 
 
@@ -154,10 +154,10 @@ def test_trace_request():
 
 
 
-def test_connection_pool_with_exception():
+def test_connection_pool_with_http_exception():
     """
-    HTTP/1.1 requests that result in an exception should not be returned to the
-    connection pool.
+    HTTP/1.1 requests that result in an exception during the connection should
+    not be returned to the connection pool.
     """
     network_backend = MockBackend([b"Wait, this isn't valid HTTP!"])
 
@@ -193,6 +193,42 @@ def test_connection_pool_with_exception():
 
 
 
+def test_connection_pool_with_connect_exception():
+    """
+    HTTP/1.1 requests that result in an exception during connection should not
+    be returned to the connection pool.
+    """
+
+    class FailedConnectBackend(MockBackend):
+        def connect_tcp(
+            self, host: str, port: int, timeout: float = None, local_address: str = None
+        ):
+            raise ConnectError("Could not connect")
+
+    network_backend = FailedConnectBackend([])
+
+    called = []
+
+    def trace(name, kwargs):
+        called.append(name)
+
+    with ConnectionPool(network_backend=network_backend) as pool:
+        # Sending an initial request, which once complete will not return to the pool.
+        with pytest.raises(Exception):
+            pool.request(
+                "GET", "https://example.com/", extensions={"trace": trace}
+            )
+
+        info = [repr(c) for c in pool.connections]
+        assert info == []
+
+    assert called == [
+        "connection.connect_tcp.started",
+        "connection.connect_tcp.failed",
+    ]
+
+
+
 def test_connection_pool_with_immediate_expiry():
     """
     Connection pools with keepalive_expiry=0.0 should immediately expire