Repository: encode/httpx

Issue:
Response not closed when timeout/cancel reading response stream, which cause RuntimeError: The connection pool was closed while 1 HTTP requests/responses were still in-flight. 
Response not closed when timeout/cancel reading response stream, which cause `RuntimeError: The connection pool was closed while 1 HTTP requests/responses were still in-flight.`

#### Reproduce:

```python
import asyncio
import httpx

async def main():
    url = "https://httpbin.org/drip?delay=0&duration=5"
    # Or use local httpbin server:
    #     docker run -ti -p 8088:80 kennethreitz/httpbin:latest
    url = "http://127.0.0.1:8088/drip?delay=0&duration=5"
    async with httpx.AsyncClient(timeout=10, trust_env=False) as client:
        try:
            coro = client.get(url)
            response = await asyncio.wait_for(coro, 3)
        except Exception as ex:
            print(type(ex), repr(ex))
        else:
            print(response)

if __name__ == "__main__":
    asyncio.run(main())
```

#### Output:

```python
<class 'asyncio.exceptions.TimeoutError'> TimeoutError()
Traceback (most recent call last):
  File "/Users/kk/dev/zhuwen/httpx/http_error2.py", line 22, in <module>
    asyncio.run(main())
  File "/Users/kk/.pyenv/versions/3.9.7/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "/Users/kk/.pyenv/versions/3.9.7/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/asyncio/base_events.py", line 642, in run_until_complete
    return future.result()
  File "/Users/kk/dev/zhuwen/httpx/http_error2.py", line 18, in main
    print(response)
  File "/Users/kk/dev/zhuwen/httpx/httpx/_client.py", line 1978, in __aexit__
    await self._transport.__aexit__(exc_type, exc_value, traceback)
  File "/Users/kk/dev/zhuwen/httpx/httpx/_transports/default.py", line 332, in __aexit__
    await self._pool.__aexit__(exc_type, exc_value, traceback)
  File "/Users/kk/.pyenv/versions/3.9.7/envs/httpx/lib/python3.9/site-packages/httpcore/_async/connection_pool.py", line 326, in __aexit__
    await self.aclose()
  File "/Users/kk/.pyenv/versions/3.9.7/envs/httpx/lib/python3.9/site-packages/httpcore/_async/connection_pool.py", line 312, in aclose
    raise RuntimeError(
RuntimeError: The connection pool was closed while 1 HTTP requests/responses were still in-flight.
```

#### Root cause:

It's because use `except Exception` in _client.py which will not catch asyncio CancelledError, so except branch will not executed. https://github.com/encode/httpx/blob/master/httpx/_client.py#L1604

```python
        try:
            if not stream:
                await response.aread()  # will raise CancelledError

            return response

        except Exception as exc:  # pragma: no cover
            await response.aclose()  # will not executed
            raise exc
```

Change all `except Exception` in _client.py to `except BaseException` can resolve the issue.


Comments:

Patch:
diff --git a/httpx/_client.py b/httpx/_client.py
index cec0d63..ce7b92c 100644
--- a/httpx/_client.py
+++ b/httpx/_client.py
@@ -900,7 +900,7 @@ class Client(BaseClient):
 
             return response
 
-        except Exception as exc:
+        except BaseException as exc:
             response.close()
             raise exc
 
@@ -932,7 +932,7 @@ class Client(BaseClient):
                     request = next_request
                     history.append(response)
 
-                except Exception as exc:
+                except BaseException as exc:
                     response.close()
                     raise exc
         finally:
@@ -971,7 +971,7 @@ class Client(BaseClient):
                     response.next_request = request
                     return response
 
-            except Exception as exc:
+            except BaseException as exc:
                 response.close()
                 raise exc
 
@@ -1604,7 +1604,7 @@ class AsyncClient(BaseClient):
 
             return response
 
-        except Exception as exc:  # pragma: no cover
+        except BaseException as exc:  # pragma: no cover
             await response.aclose()
             raise exc
 
@@ -1636,7 +1636,7 @@ class AsyncClient(BaseClient):
                     request = next_request
                     history.append(response)
 
-                except Exception as exc:
+                except BaseException as exc:
                     await response.aclose()
                     raise exc
         finally:
@@ -1676,7 +1676,7 @@ class AsyncClient(BaseClient):
                     response.next_request = request
                     return response
 
-            except Exception as exc:
+            except BaseException as exc:
                 await response.aclose()
                 raise exc
 


Test patch:
diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py
index 219d612..da2387d 100644
--- a/tests/client/test_async_client.py
+++ b/tests/client/test_async_client.py
@@ -324,6 +324,46 @@ async def test_async_mock_transport():
         assert response.text == "Hello, world!"
 
 
+@pytest.mark.usefixtures("async_environment")
+async def test_cancellation_during_stream():
+    """
+    If any BaseException is raised during streaming the response, then the
+    stream should be closed.
+
+    This includes:
+
+    * `asyncio.CancelledError` (A subclass of BaseException from Python 3.8 onwards.)
+    * `trio.Cancelled`
+    * `KeyboardInterrupt`
+    * `SystemExit`
+
+    See https://github.com/encode/httpx/issues/2139
+    """
+    stream_was_closed = False
+
+    def response_with_cancel_during_stream(request):
+        class CancelledStream(httpx.AsyncByteStream):
+            async def __aiter__(self) -> typing.AsyncIterator[bytes]:
+                yield b"Hello"
+                raise KeyboardInterrupt()
+                yield b", world"  # pragma: nocover
+
+            async def aclose(self) -> None:
+                nonlocal stream_was_closed
+                stream_was_closed = True
+
+        return httpx.Response(
+            200, headers={"Content-Length": "12"}, stream=CancelledStream()
+        )
+
+    transport = httpx.MockTransport(response_with_cancel_during_stream)
+
+    async with httpx.AsyncClient(transport=transport) as client:
+        with pytest.raises(KeyboardInterrupt):
+            await client.get("https://www.example.com")
+        assert stream_was_closed
+
+
 @pytest.mark.usefixtures("async_environment")
 async def test_server_extensions(server):
     url = server.url