Repository: encode/starlette

Issue:
Detect/handle closed client connections
It appears like starlette does not cancel inner processing for when a client disconnects.

With the following code it will keep on fetching from the remote, although the
client has disconnected already, e.g. when cancelling a curl call.

```python
@app.route(…)
async def foo(request):
    …
    if content_type.startswith("video/"):
        bytes_total = req.headers["Content-Length"]
        logger.info("Streaming {} bytes...", bytes_total)

        async def generator(bytes_total):
            import aiohttp
            async with aiohttp.ClientSession() as session:
                chunk = 0
                chunk_size = 102400
                bytes_read = 0
                async with session.request(method, str(url), chunked=chunk_size) as resp:
                    async for data, end_of_http_chunk in resp.content.iter_chunks():
                        chunk += 1
                        bytes_read += chunk_size
                        logger.info("chunk {} {}/{}", chunk, bytes_read, bytes_total)
                        yield data
        return StreamingResponse(generator(bytes_total), media_type=content_type)
```

It might be useful if the handler could be notified of this, in case it wants
to handle this, but in general I think a late middleware, responsible for
sending could handle this maybe?!



Discussion:
btw: I've looked a bit into requests-threads etc, but it appears to not be possible really to use requests inside of a handler asynchronously, is it?
I've found that uvicorn just ignores it in `send`: https://github.com/encode/uvicorn/blob/83442255299f49a6e9b0a0ea3935992bc3e3dc81/uvicorn/protocols/http/httptools_impl.py#L432-L433
Should it or rather Starlette maybe raise `ClientDisconnect` like with reading the request?  https://github.com/encode/uvicorn/blob/83442255299f49a6e9b0a0ea3935992bc3e3dc81/uvicorn/protocols/http/httptools_impl.py#L432-L433

Anyway, the following example app works with uvicorn:
```python
import asyncio

import logging
logger = logging.getLogger(__name__)


class App():
    def __init__(self, scope):
        self.scope = scope

    async def __call__(self, receive, send):
        await send({
            'type': 'http.response.start',
            'status': 200,
            'headers': [
                [b'content-type', b'text/plain'],
            ],
        })
        for i in range(10):
            await send({
                'type': 'http.response.body',
                'body': str(i).encode(),
                'more_body': True,
            })
            logger.info("sent: %d", i)
            await asyncio.sleep(1)

            if receive.__self__.disconnected:
                logger.info("disconnected")
                break

        await send({
            'type': 'http.response.body',
            'body': '',
            'more_body': False,
        })
```

@blueyed I believe this is a limitation of the ASGI spec itself. The [Error handling](https://asgi.readthedocs.io/en/latest/specs/main.html#error-handling) section reads:

> Note messages received by a server after the connection has been closed are not considered errors. In this case the send awaitable callable should act as a no-op.

And that's why `send()` just returns when the client has disconnected.

I noticed that @tomchristie already started the discussion on this https://github.com/django/asgiref/issues/66, though.

That said, I second the need to know when a client has disconnected, especially when dealing with `StreamingResponse`s. I'm trying to implement SSE manually by returning a `StreamingResponse`,  but the generator cannot know when the client disconnects and will keep sending events (although to a no-op) indefinitely.
@florimondmanca 
Thanks for the pointer(s).

I think checking `receive.__self__.disconnected` would also work for you, wouldn't it?

(I've initially tried `await receive()`, where a disconnect message would appear, but that might block without any message(s))
Yup, the `__self__` trick does work for me. Also tried hacking `StreamingResponse` to check for `http.disconnect` on each iteration of the body iterator, but that won't work because it blocks as you've just said. :) The only way I've found was raising a disconnect exception and wrapping the `await response(receive, send)` call around a try/except block. I understand this is not the philosophy of ASGI for now, but it works. That said, I'm not sure about whether this skips necessary cleanup on the uvicorn side (e.g. releasing connections).
> I'm trying to implement SSE manually by returning a StreamingResponse, but the generator cannot know when the client disconnects and will keep sending events (although to a no-op) indefinitely.

Right. The thing is that the disconnect needs to be handled in an event driven way, rather than raising an exception on `send()`.

> I've initially tried await receive(), where a disconnect message would appear, but that might block without any message(s)

Sure, so I think what you'd need is something like:

```python
done, pending = await asyncio.wait([receive, response.content.read], return_when=FIRST_COMPLETED)
task = done[0]
if task is receive:
    message = task.result()
else:
    data = task.result()
```

Not wild about how akward it is to do a await either this thing or that thing and switch on either,
but there we are.

I think the following would also do the trick, for a non-blocking receive: `message = await asyncio.wait_for(receive, timeout=0)`.


Resolving patch:
diff --git a/starlette/requests.py b/starlette/requests.py
--- a/starlette/requests.py
+++ b/starlette/requests.py
@@ -1,3 +1,4 @@
+import asyncio
 import http.cookies
 import json
 import typing
@@ -120,6 +121,7 @@ def __init__(self, scope: Scope, receive: Receive = empty_receive):
         assert scope["type"] == "http"
         self._receive = receive
         self._stream_consumed = False
+        self._is_disconnected = False
 
     @property
     def method(self) -> str:
@@ -148,6 +150,7 @@ async def stream(self) -> typing.AsyncGenerator[bytes, None]:
                 if not message.get("more_body", False):
                     break
             elif message["type"] == "http.disconnect":
+                self._is_disconnected = True
                 raise ClientDisconnect()
         yield b""
 
@@ -187,3 +190,15 @@ async def close(self) -> None:
             for item in self._form.values():
                 if hasattr(item, "close"):
                     await item.close()  # type: ignore
+
+    async def is_disconnected(self) -> bool:
+        if not self._is_disconnected:
+            try:
+                message = await asyncio.wait_for(self._receive(), timeout=0.0000001)
+            except asyncio.TimeoutError as exc:
+                message = {}
+
+            if message.get("type") == "http.disconnect":
+                self._is_disconnected = True
+
+        return self._is_disconnected
diff --git a/starlette/testclient.py b/starlette/testclient.py
--- a/starlette/testclient.py
+++ b/starlette/testclient.py
@@ -129,6 +129,13 @@ def send(  # type: ignore
         }
 
         async def receive() -> Message:
+            nonlocal request_complete, response_complete
+
+            if request_complete:
+                while not response_complete:
+                    await asyncio.sleep(0.0001)
+                return {"type": "http.disconnect"}
+
             body = request.body
             if isinstance(body, str):
                 body_bytes = body.encode("utf-8")  # type: bytes
@@ -141,9 +148,12 @@ async def receive() -> Message:
                         chunk = chunk.encode("utf-8")
                     return {"type": "http.request", "body": chunk, "more_body": True}
                 except StopIteration:
+                    request_complete = True
                     return {"type": "http.request", "body": b""}
             else:
                 body_bytes = body
+
+            request_complete = True
             return {"type": "http.request", "body": body_bytes}
 
         async def send(message: Message) -> None:
@@ -182,6 +192,7 @@ async def send(message: Message) -> None:
                 template = message["template"]
                 context = message["context"]
 
+        request_complete = False
         response_started = False
         response_complete = False
         raw_kwargs = {"body": io.BytesIO()}  # type: typing.Dict[str, typing.Any]


Test patch:
diff --git a/tests/test_requests.py b/tests/test_requests.py
--- a/tests/test_requests.py
+++ b/tests/test_requests.py
@@ -254,6 +254,32 @@ async def receiver():
         loop.run_until_complete(asgi_callable(receiver, None))
 
 
+def test_request_is_disconnected():
+    """
+    If a client disconnect occurs while reading request body
+    then ClientDisconnect should be raised.
+    """
+    disconnected_after_response = None
+
+    def app(scope):
+        async def asgi(receive, send):
+            nonlocal disconnected_after_response
+
+            request = Request(scope, receive)
+            await request.body()
+            disconnected = await request.is_disconnected()
+            response = JSONResponse({"disconnected": disconnected})
+            await response(receive, send)
+            disconnected_after_response = await request.is_disconnected()
+
+        return asgi
+
+    client = TestClient(app)
+    response = client.get("/")
+    assert response.json() == {"disconnected": False}
+    assert disconnected_after_response
+
+
 def test_request_cookies():
     def app(scope):
         async def asgi(receive, send):