florimondmanca: I like this. The `request.is_disconnected()` API is clean and lets the user define what they want to do with it.

I'm just wondering about calling this function periodically and potentially very frequently. Consider the streaming response use case, i.e. calling `request.is_disconnected()` to break the generator when the client has disconnected:

```python
@app.route('/foo')
async def foo(request):
    async def chunks(num):
        for i in range(num):
            if await request.is_disconnected():
               break
            yield f"chunk #{i}"
    return StreamingResponse(chunks(1_000_000))
```

Is there any performance issue to be expected in this context? I suppose the 0.1µs timeout was chosen to avoid any overhead.
diff --git a/docs/requests.md b/docs/requests.md
index 295fb85..d0cbfe7 100644
--- a/docs/requests.md
+++ b/docs/requests.md
@@ -109,3 +109,7 @@ class App:
 If you access `.stream()` then the byte chunks are provided without storing
 the entire body to memory. Any subsequent calls to `.body()`, `.form()`, or `.json()`
 will raise an error.
+
+In some cases such as long-polling, or streaming responses you might need to
+determine if the client has dropped the connection. You can determine this
+state with `disconnected = await request.is_disconnected()`.
diff --git a/starlette/requests.py b/starlette/requests.py
index 1029d97..5b703ce 100644
--- 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 @@ class Request(HTTPConnection):
         assert scope["type"] == "http"
         self._receive = receive
         self._stream_consumed = False
+        self._is_disconnected = False
 
     @property
     def method(self) -> str:
@@ -148,6 +150,7 @@ class Request(HTTPConnection):
                 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 @@ class Request(HTTPConnection):
             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

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?!

diff --git a/starlette/testclient.py b/starlette/testclient.py
index 01b843a..dd3f297 100644
--- a/starlette/testclient.py
+++ b/starlette/testclient.py
@@ -129,6 +129,13 @@ class _ASGIAdapter(requests.adapters.HTTPAdapter):
         }
 
         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 @@ class _ASGIAdapter(requests.adapters.HTTPAdapter):
                         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 @@ class _ASGIAdapter(requests.adapters.HTTPAdapter):
                 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]
diff --git a/tests/test_requests.py b/tests/test_requests.py
index 9e02c9f..32d0ab6 100644
--- a/tests/test_requests.py
+++ b/tests/test_requests.py
@@ -254,6 +254,32 @@ def test_request_disconnect():
         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):

pip install -e .[full]
parse_log_pytest
requirements.txt
pytest pytest-cov mypy autoflake black isort
3.9
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
aiofiles==24.1.0
async-timeout==5.0.1
asyncpg==0.30.0
autoflake==2.3.1
babel==2.17.0
backrefs==5.8
black==25.1.0
certifi==2025.1.31
charset-normalizer==3.4.1
click==8.1.8
codecov==2.1.13
colorama==0.4.6
coverage==7.8.0
exceptiongroup==1.2.2
ghp-import==2.1.0
graphene==3.4.3
graphql-core==3.2.6
graphql-relay==3.2.0
greenlet==3.1.1
idna==3.10
importlib_metadata==8.6.1
iniconfig==2.1.0
isort==6.0.1
itsdangerous==2.2.0
Jinja2==3.1.6
Markdown==3.7
MarkupSafe==3.0.2
mergedeep==1.3.4
mkdocs==1.6.1
mkdocs-get-deps==0.2.0
mkdocs-material==9.6.10
mkdocs-material-extensions==1.3.1
mypy==1.15.0
mypy-extensions==1.0.0
packaging==24.2
paginate==0.5.7
pathspec==0.12.1
platformdirs==4.3.7
pluggy==1.5.0
psycopg2-binary==2.9.10
pyflakes==3.3.2
Pygments==2.19.1
pymdown-extensions==10.14.3
pytest==8.3.5
pytest-cov==6.0.0
python-dateutil==2.9.0.post0
python-multipart==0.0.20
PyYAML==6.0.2
pyyaml_env_tag==0.1
requests==2.32.3
six==1.17.0
SQLAlchemy==2.0.40
-e git+https://github.com/encode/starlette.git@af105b23d53efc41265793d4d74c2720038be529#egg=starlette
tomli==2.2.1
typing_extensions==4.13.0
ujson==5.10.0
urllib3==2.3.0
watchdog==6.0.0
zipp==3.21.0

name: starlette
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - aiofiles==24.1.0
      - async-timeout==5.0.1
      - asyncpg==0.30.0
      - autoflake==2.3.1
      - babel==2.17.0
      - backrefs==5.8
      - black==25.1.0
      - certifi==2025.1.31
      - charset-normalizer==3.4.1
      - click==8.1.8
      - codecov==2.1.13
      - colorama==0.4.6
      - coverage==7.8.0
      - exceptiongroup==1.2.2
      - ghp-import==2.1.0
      - graphene==3.4.3
      - graphql-core==3.2.6
      - graphql-relay==3.2.0
      - greenlet==3.1.1
      - idna==3.10
      - importlib-metadata==8.6.1
      - iniconfig==2.1.0
      - isort==6.0.1
      - itsdangerous==2.2.0
      - jinja2==3.1.6
      - markdown==3.7
      - markupsafe==3.0.2
      - mergedeep==1.3.4
      - mkdocs==1.6.1
      - mkdocs-get-deps==0.2.0
      - mkdocs-material==9.6.10
      - mkdocs-material-extensions==1.3.1
      - mypy==1.15.0
      - mypy-extensions==1.0.0
      - packaging==24.2
      - paginate==0.5.7
      - pathspec==0.12.1
      - platformdirs==4.3.7
      - pluggy==1.5.0
      - psycopg2-binary==2.9.10
      - pyflakes==3.3.2
      - pygments==2.19.1
      - pymdown-extensions==10.14.3
      - pytest==8.3.5
      - pytest-cov==6.0.0
      - python-dateutil==2.9.0.post0
      - python-multipart==0.0.20
      - pyyaml==6.0.2
      - pyyaml-env-tag==0.1
      - requests==2.32.3
      - six==1.17.0
      - sqlalchemy==2.0.40
      - tomli==2.2.1
      - typing-extensions==4.13.0
      - ujson==5.10.0
      - urllib3==2.3.0
      - watchdog==6.0.0
      - zipp==3.21.0
prefix: /opt/conda/envs/starlette

tests/test_requests.py::test_request_is_disconnected
tests/test_requests.py::test_request_url tests/test_requests.py::test_request_query_params tests/test_requests.py::test_request_headers tests/test_requests.py::test_request_client tests/test_requests.py::test_request_body tests/test_requests.py::test_request_stream tests/test_requests.py::test_request_form_urlencoded tests/test_requests.py::test_request_body_then_stream tests/test_requests.py::test_request_stream_then_body tests/test_requests.py::test_request_json tests/test_requests.py::test_request_scope_interface tests/test_requests.py::test_request_without_setting_receive tests/test_requests.py::test_request_disconnect tests/test_requests.py::test_request_cookies tests/test_requests.py::test_chunked_encoding
docs/requests.md starlette/requests.py
docs/requests.md starlette/requests.py