Repository: encode/httpx

Issue:
Add warning about unclosed AsyncClient
It would be nice to have an info about some unclosed `AsyncClient()` instance. Here is an example how it is implemented in aiohttp: https://github.com/aio-libs/aiohttp/blob/693ed0cce0c67646f8df18c93e448d10782c2811/aiohttp/client.py#L268-L286


Discussion:
Hi @kamilglod 

Can you share what the UX for this in aiohttp looks like? (eg maybe a snippet of an interactive interpreter session…)

I'm just wondering if this would actually provide value, or just add noise when shutting down an interpreter (which should force-close any dangling resources anyway, no?).
@florimondmanca closing all connections sounds fine but question is whether it is technically doable as `close()` is an async function and we cannot `await` it inside `__dell__()`. Additionally I think that such an information is very helpful in tracking buggy code with unclosed resources
Here is an example output from aiohttp: 
![Screenshot from 2020-03-25 12-40-08](https://user-images.githubusercontent.com/8168459/77532796-e470c680-6e95-11ea-88ab-bb5564c4c122.png)
Thanks, the REPL snippet is useful.

An implementation similar to aiohttp is technically possible — ie log a warning but don’t attempt to close the client (notably because, as you said, it’s an async operation).

One bit to keep in mind is that this would need to be async library agnostic (asyncio/trio) - so the check needs to be deferred to the async backends at some point - so this doesn’t seem like a trivial addition. But if you’d like to give it a shot feel free to. :)
@florimondmanca If anyone is not working on it. I would like to give it a try. 😄 
I was setting up the project locally and in the step of installing dependencies. Should it explicitly mention that `scripts/install` will automatically create a virtual environment?

> You can now install the project and its dependencies using:
   $ cd httpx
   $ scripts/install

Or is it just me that went ahead creating a virtual env and running the command `scripts/install` hoping it would install dependencies in the environment I created.  
@Pradhvan do you mind if I take this?
@florimondmanca should we add the similar check for `httpx.Client`?
@cdeler yes 😄 

Resolving patch:
diff --git a/httpx/_client.py b/httpx/_client.py
--- a/httpx/_client.py
+++ b/httpx/_client.py
@@ -1,5 +1,6 @@
 import functools
 import typing
+import warnings
 from types import TracebackType
 
 import httpcore
@@ -79,6 +80,14 @@ def __init__(
         self.max_redirects = max_redirects
         self._trust_env = trust_env
         self._netrc = NetRCInfo()
+        self._is_closed = True
+
+    @property
+    def is_closed(self) -> bool:
+        """
+        Check if the client being closed
+        """
+        return self._is_closed
 
     @property
     def trust_env(self) -> bool:
@@ -696,6 +705,8 @@ def send(
 
         [0]: /advanced/#request-instances
         """
+        self._is_closed = False
+
         timeout = self.timeout if isinstance(timeout, UnsetType) else Timeout(timeout)
 
         auth = self._build_request_auth(request, auth)
@@ -1029,16 +1040,20 @@ def close(self) -> None:
         """
         Close transport and proxies.
         """
-        self._transport.close()
-        for proxy in self._proxies.values():
-            if proxy is not None:
-                proxy.close()
+        if not self.is_closed:
+            self._is_closed = True
+
+            self._transport.close()
+            for proxy in self._proxies.values():
+                if proxy is not None:
+                    proxy.close()
 
     def __enter__(self) -> "Client":
         self._transport.__enter__()
         for proxy in self._proxies.values():
             if proxy is not None:
                 proxy.__enter__()
+        self._is_closed = False
         return self
 
     def __exit__(
@@ -1047,10 +1062,16 @@ def __exit__(
         exc_value: BaseException = None,
         traceback: TracebackType = None,
     ) -> None:
-        self._transport.__exit__(exc_type, exc_value, traceback)
-        for proxy in self._proxies.values():
-            if proxy is not None:
-                proxy.__exit__(exc_type, exc_value, traceback)
+        if not self.is_closed:
+            self._is_closed = True
+
+            self._transport.__exit__(exc_type, exc_value, traceback)
+            for proxy in self._proxies.values():
+                if proxy is not None:
+                    proxy.__exit__(exc_type, exc_value, traceback)
+
+    def __del__(self) -> None:
+        self.close()
 
 
 class AsyncClient(BaseClient):
@@ -1305,6 +1326,8 @@ async def send(
 
         [0]: /advanced/#request-instances
         """
+        self._is_closed = False
+
         timeout = self.timeout if isinstance(timeout, UnsetType) else Timeout(timeout)
 
         auth = self._build_request_auth(request, auth)
@@ -1640,16 +1663,20 @@ async def aclose(self) -> None:
         """
         Close transport and proxies.
         """
-        await self._transport.aclose()
-        for proxy in self._proxies.values():
-            if proxy is not None:
-                await proxy.aclose()
+        if not self.is_closed:
+            self._is_closed = True
+
+            await self._transport.aclose()
+            for proxy in self._proxies.values():
+                if proxy is not None:
+                    await proxy.aclose()
 
     async def __aenter__(self) -> "AsyncClient":
         await self._transport.__aenter__()
         for proxy in self._proxies.values():
             if proxy is not None:
                 await proxy.__aenter__()
+        self._is_closed = False
         return self
 
     async def __aexit__(
@@ -1658,10 +1685,20 @@ async def __aexit__(
         exc_value: BaseException = None,
         traceback: TracebackType = None,
     ) -> None:
-        await self._transport.__aexit__(exc_type, exc_value, traceback)
-        for proxy in self._proxies.values():
-            if proxy is not None:
-                await proxy.__aexit__(exc_type, exc_value, traceback)
+        if not self.is_closed:
+            self._is_closed = True
+            await self._transport.__aexit__(exc_type, exc_value, traceback)
+            for proxy in self._proxies.values():
+                if proxy is not None:
+                    await proxy.__aexit__(exc_type, exc_value, traceback)
+
+    def __del__(self) -> None:
+        if not self.is_closed:
+            warnings.warn(
+                f"Unclosed {self!r}. "
+                "See https://www.python-httpx.org/async/#opening-and-closing-clients "
+                "for details."
+            )
 
 
 class StreamContextManager:


Test patch:
diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py
--- a/tests/client/test_async_client.py
+++ b/tests/client/test_async_client.py
@@ -206,3 +206,45 @@ async def __aexit__(self, *args):
         "transport.aclose",
         "transport.__aexit__",
     ]
+
+
+@pytest.mark.usefixtures("async_environment")
+async def test_that_async_client_is_closed_by_default():
+    client = httpx.AsyncClient()
+
+    assert client.is_closed
+
+
+@pytest.mark.usefixtures("async_environment")
+async def test_that_send_cause_async_client_to_be_not_closed():
+    client = httpx.AsyncClient()
+
+    await client.get("http://example.com")
+
+    assert not client.is_closed
+
+    await client.aclose()
+
+
+@pytest.mark.usefixtures("async_environment")
+async def test_that_async_client_is_not_closed_in_with_block():
+    async with httpx.AsyncClient() as client:
+        assert not client.is_closed
+
+
+@pytest.mark.usefixtures("async_environment")
+async def test_that_async_client_is_closed_after_with_block():
+    async with httpx.AsyncClient() as client:
+        pass
+
+    assert client.is_closed
+
+
+@pytest.mark.usefixtures("async_environment")
+async def test_that_async_client_caused_warning_when_being_deleted():
+    async_client = httpx.AsyncClient()
+
+    await async_client.get("http://example.com")
+
+    with pytest.warns(UserWarning):
+        del async_client
diff --git a/tests/client/test_client.py b/tests/client/test_client.py
--- a/tests/client/test_client.py
+++ b/tests/client/test_client.py
@@ -247,3 +247,29 @@ def __exit__(self, *args):
         "transport.close",
         "transport.__exit__",
     ]
+
+
+def test_that_client_is_closed_by_default():
+    client = httpx.Client()
+
+    assert client.is_closed
+
+
+def test_that_send_cause_client_to_be_not_closed():
+    client = httpx.Client()
+
+    client.get("http://example.com")
+
+    assert not client.is_closed
+
+
+def test_that_client_is_not_closed_in_with_block():
+    with httpx.Client() as client:
+        assert not client.is_closed
+
+
+def test_that_client_is_closed_after_with_block():
+    with httpx.Client() as client:
+        pass
+
+    assert client.is_closed
diff --git a/tests/client/test_proxies.py b/tests/client/test_proxies.py
--- a/tests/client/test_proxies.py
+++ b/tests/client/test_proxies.py
@@ -123,14 +123,16 @@ def test_transport_for_request(url, proxies, expected):
 @pytest.mark.asyncio
 async def test_async_proxy_close():
     try:
-        client = httpx.AsyncClient(proxies={"all://": PROXY_URL})
+        client = httpx.AsyncClient(proxies={"https://": PROXY_URL})
+        await client.get("http://example.com")
     finally:
         await client.aclose()
 
 
 def test_sync_proxy_close():
     try:
-        client = httpx.Client(proxies={"all://": PROXY_URL})
+        client = httpx.Client(proxies={"https://": PROXY_URL})
+        client.get("http://example.com")
     finally:
         client.close()