Repository: falconry/falcon
Pull request #1798 (2020-12-09 09:25:37)

fix: do not trigger request processing until after receiving first event

codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1798?src=pr&el=h1) Report
> Merging [#1798](https://codecov.io/gh/falconry/falcon/pull/1798?src=pr&el=desc) (9e86fd2) into [master](https://codecov.io/gh/falconry/falcon/commit/1df2c0b7f21de773b3de70ea44af26f225c1887c?el=desc) (1df2c0b) will **decrease** coverage by `38.07%`.
> The diff coverage is `2.00%`.

[![Impacted file tree graph](https://codecov.io/gh/falconry/falcon/pull/1798/graphs/tree.svg?width=650&height=150&src=pr&token=zOtkfNQ3h6)](https://codecov.io/gh/falconry/falcon/pull/1798?src=pr&el=tree)

```diff
@@             Coverage Diff              @@
##            master    #1798       +/-   ##
============================================
- Coverage   100.00%   61.92%   -38.08%     
============================================
  Files           54       54               
  Lines         5049     5074       +25     
  Branches       797      807       +10     
============================================
- Hits          5049     3142     -1907     
- Misses           0     1922     +1922     
- Partials         0       10       +10     
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1798?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/asgi/app.py](https://codecov.io/gh/falconry/falcon/pull/1798/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvYXBwLnB5) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [falcon/asgi/request.py](https://codecov.io/gh/falconry/falcon/pull/1798/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvcmVxdWVzdC5weQ==) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [falcon/asgi/stream.py](https://codecov.io/gh/falconry/falcon/pull/1798/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvc3RyZWFtLnB5) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [falcon/testing/client.py](https://codecov.io/gh/falconry/falcon/pull/1798/diff?src=pr&el=tree#diff-ZmFsY29uL3Rlc3RpbmcvY2xpZW50LnB5) | `63.09% <0.00%> (-36.91%)` | :arrow_down: |
| [falcon/testing/helpers.py](https://codecov.io/gh/falconry/falcon/pull/1798/diff?src=pr&el=tree#diff-ZmFsY29uL3Rlc3RpbmcvaGVscGVycy5weQ==) | `36.65% <0.00%> (-63.35%)` | :arrow_down: |
| [falcon/errors.py](https://codecov.io/gh/falconry/falcon/pull/1798/diff?src=pr&el=tree#diff-ZmFsY29uL2Vycm9ycy5weQ==) | `99.46% <100.00%> (-0.54%)` | :arrow_down: |
| [falcon/asgi/ws.py](https://codecov.io/gh/falconry/falcon/pull/1798/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvd3MucHk=) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [falcon/asgi/reader.py](https://codecov.io/gh/falconry/falcon/pull/1798/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvcmVhZGVyLnB5) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [falcon/asgi/response.py](https://codecov.io/gh/falconry/falcon/pull/1798/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvcmVzcG9uc2UucHk=) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [falcon/asgi/multipart.py](https://codecov.io/gh/falconry/falcon/pull/1798/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvbXVsdGlwYXJ0LnB5) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| ... and [31 more](https://codecov.io/gh/falconry/falcon/pull/1798/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1798?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/falcon/pull/1798?src=pr&el=footer). Last update [1df2c0b...9e86fd2](https://codecov.io/gh/falconry/falcon/pull/1798?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

kgriffs: > Just intuitively, I would choose to filter empty data chunks in BoundedStream._iter_content(), and not yield them downstream.

IIRC I need to suppress emitting empty body chunks to ensure test coverage of a particular branch (not related to `_iter_content()`. Regardless, I like the idea of filtering on iteration and modifying the EOF check to "filter" as well by not returning any chunks. I'll make this change in the next PR update so we can see what this would look like.
kgriffs: @vytas7 Improved handling of empty request chunks. Let me know what you think.

Patch:
diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py
index c7fdecd..737086e 100644
--- a/falcon/asgi/app.py
+++ b/falcon/asgi/app.py
@@ -359,8 +359,21 @@ class App(falcon.app.App):
                 f'The ASGI http scope version {spec_version} is not supported.'
             )
 
+        # NOTE(kgriffs): Per the ASGI spec, we should not proceed with request
+        #   processing until after we receive an initial 'http.request' event.
+        first_event = await receive()
+        first_event_type = first_event['type']
+        if first_event_type == EventType.HTTP_DISCONNECT:
+            # NOTE(kgriffs): Bail out immediately to minimize resource usage
+            return
+
+        # NOTE(kgriffs): This is the only other type defined by the ASGI spec,
+        #   but we just assert it to make it easier to track down a potential
+        #   incompatibility with a future spec version.
+        assert first_event_type == EventType.HTTP_REQUEST
+
+        req = self._request_type(scope, receive, first_event=first_event, options=self.req_options)
         resp = self._response_type(options=self.resp_options)
-        req = self._request_type(scope, receive, options=self.req_options)
 
         if self.req_options.auto_parse_form_urlencoded:
             raise UnsupportedError(
@@ -849,8 +862,10 @@ class App(falcon.app.App):
     async def _handle_websocket(self, ver, scope, receive, send):
         first_event = await receive()
         if first_event['type'] != EventType.WS_CONNECT:
-            # The handshake was abandoned or this is a message we don't
-            #   support, so bail out.
+            # NOTE(kgriffs): The handshake was abandoned or this is a message
+            #   we don't support, so bail out. This also fulfills the ASGI
+            #   spec requirement to only process the request after
+            #   receiving and verifying the first event.
             await send({
                 'type': EventType.WS_CLOSE,
                 'code': WSCloseCode.SERVER_ERROR
diff --git a/falcon/asgi/request.py b/falcon/asgi/request.py
index e1a7dc3..e798af0 100644
--- a/falcon/asgi/request.py
+++ b/falcon/asgi/request.py
@@ -41,6 +41,8 @@ class Request(falcon.request.Request):
             event dictionary when one is available.
 
     Keyword Args:
+        first_event (dict): First ASGI event received from the client,
+            if one was preloaded (default ``None``).
         options (falcon.request.RequestOptions): Set of global request options
             passed from the App handler.
 
@@ -342,12 +344,13 @@ class Request(falcon.request.Request):
     __slots__ = [
         '_asgi_headers',
         '_asgi_server_cached',
+        '_first_event',
         '_receive',
         '_stream',
         'scope',
     ]
 
-    def __init__(self, scope, receive, options=None):
+    def __init__(self, scope, receive, first_event=None, options=None):
 
         # =====================================================================
         # Prepare headers
@@ -450,6 +453,7 @@ class Request(falcon.request.Request):
 
         self._stream = None
         self._receive = receive
+        self._first_event = first_event
 
         # =====================================================================
         # Create a context object
@@ -519,7 +523,11 @@ class Request(falcon.request.Request):
             )
 
         if not self._stream:
-            self._stream = BoundedStream(self._receive, self.content_length)
+            self._stream = BoundedStream(
+                self._receive,
+                first_event=self._first_event,
+                content_length=self.content_length
+            )
 
         return self._stream
 
diff --git a/falcon/asgi/stream.py b/falcon/asgi/stream.py
index caba1a1..015425e 100644
--- a/falcon/asgi/stream.py
+++ b/falcon/asgi/stream.py
@@ -38,6 +38,10 @@ class BoundedStream:
     buffered by the framework, this is the most memory-efficient way of reading
     the request body::
 
+        # If the request body is empty or has already be consumed, the iteration
+        #   will immediately stop without yielding any data chunks. Otherwise, a
+        #   series of byte #   strings will be yielded until the entire request
+        #   body has been yielded or the client disconnects.
         async for data_chunk in req.stream
             pass
 
@@ -85,6 +89,8 @@ class BoundedStream:
             request event dictionary when one is available.
 
     Keyword Args:
+        first_event (dict): First ASGI event received from the client,
+            if one was preloaded (default ``None``).
         content_length (int): Expected content length of the stream, derived
             from the Content-Length header in the request (if available).
     """
@@ -98,22 +104,46 @@ class BoundedStream:
         '_receive',
     ]
 
-    def __init__(self, receive, content_length=None):
+    def __init__(self, receive, first_event=None, content_length=None):
         self._closed = False
         self._iteration_started = False
 
         self._receive = receive
-        self._buffer = b''
 
-        # NOTE(kgriffs): If length is unknown we just set remaining bytes
-        #   to a ridiculously high number so that we will keep reading
-        #   until we get an event with more_body == False. We do not
-        #   use sys.maxsize because 2**31 on 32-bit systems is not
-        #   a large enough number (someone may have an API that accepts
-        #   multi-GB payloads).
-        self._bytes_remaining = 2**63 if content_length is None else content_length
+        # NOTE(kgriffs): Outside of testing, first_event will always be set
+        #   and we also assume a body is expected, otherwise why bother
+        #   creating a stream object to read it? But just in case this
+        #   object is created in other cases, use "in" here rather than
+        #   EAFP.
+        if first_event and 'body' in first_event:
+            first_chunk = first_event['body']
+        else:
+            first_chunk = b''
+
+        if content_length is None:
+            self._buffer = first_chunk
+
+            # NOTE(kgriffs): If length is unknown we just set remaining bytes
+            #   to a ridiculously high number so that we will keep reading
+            #   until we get an event with more_body == False. We do not
+            #   use sys.maxsize because 2**31 on 32-bit systems is not
+            #   a large enough number (someone may have an API that accepts
+            #   multi-GB payloads).
+            self._bytes_remaining = 2**63
+        else:
+            if len(first_chunk) > content_length:
+                self._buffer = first_chunk[:content_length]
+            else:
+                self._buffer = first_chunk
+
+            self._bytes_remaining = content_length - len(self._buffer)
+
+        self._pos = len(self._buffer)
 
-        self._pos = 0
+        if first_event and self._bytes_remaining:
+            # NOTE(kgriffs): Override if the event says there's no more data
+            if not ('more_body' in first_event and first_event['more_body']):
+                self._bytes_remaining = 0
 
     def __aiter__(self):
         # NOTE(kgriffs): This returns an async generator, but that's OK because
@@ -379,7 +409,6 @@ class BoundedStream:
             )
 
         if self.eof:
-            yield b''
             return
 
         if self._iteration_started:
@@ -404,6 +433,10 @@ class BoundedStream:
             except KeyError:
                 pass
             else:
+                # NOTE(kgriffs): No need to yield empty body chunks.
+                if not next_chunk:
+                    continue
+
                 next_chunk_len = len(next_chunk)
 
                 if next_chunk_len <= self._bytes_remaining:
diff --git a/falcon/errors.py b/falcon/errors.py
index 09df2f8..26fda8d 100644
--- a/falcon/errors.py
+++ b/falcon/errors.py
@@ -73,7 +73,7 @@ class PayloadTypeError(TypeError):
     """The WebSocket message payload was not of the expected type."""
 
 
-class WebSocketDisconnected(RuntimeError):
+class WebSocketDisconnected(ConnectionError):
     """The websocket connection is lost.
 
     This error is raised when attempting to perform an operation on the


Test patch:
diff --git a/falcon/testing/client.py b/falcon/testing/client.py
index f6155db..6d34e90 100644
--- a/falcon/testing/client.py
+++ b/falcon/testing/client.py
@@ -710,7 +710,11 @@ async def _simulate_request_asgi(
     http_scope.update(extras)
     # ---------------------------------------------------------------------
 
-    disconnect_at = time.time() + max(0, asgi_disconnect_ttl)
+    if asgi_disconnect_ttl == 0:  # Special case
+        disconnect_at = 0
+    else:
+        disconnect_at = time.time() + max(0, asgi_disconnect_ttl)
+
     req_event_emitter = helpers.ASGIRequestEventEmitter(
         (body or b''),
         chunk_size=asgi_chunk_size,
@@ -781,6 +785,11 @@ async def _simulate_request_asgi(
 
     await conductor()
 
+    if resp_event_collector.status is None:
+        # NOTE(kgriffs): An immediate disconnect was simulated, and so
+        #   the app could not return a status.
+        raise ConnectionError('An immediate disconnect was simulated.')
+
     return Result(resp_event_collector.body_chunks,
                   code_to_http_status(resp_event_collector.status),
                   resp_event_collector.headers)
@@ -1127,7 +1136,8 @@ def simulate_get(app, path, **kwargs) -> _ResultBase:
         asgi_disconnect_ttl (int): The maximum number of seconds to wait
             since the request was initiated, before emitting an
             'http.disconnect' event when the app calls the
-            receive() function (default 300).
+            receive() function (default 300). Set to ``0`` to simulate an
+            immediate disconnection without first emitting 'http.request'.
         extras (dict): Additional values to add to the WSGI
             ``environ`` dictionary or the ASGI scope for the request
             (default: ``None``)
@@ -1218,7 +1228,8 @@ def simulate_head(app, path, **kwargs) -> _ResultBase:
         asgi_disconnect_ttl (int): The maximum number of seconds to wait
             since the request was initiated, before emitting an
             'http.disconnect' event when the app calls the
-            receive() function (default 300).
+            receive() function (default 300). Set to ``0`` to simulate an
+            immediate disconnection without first emitting 'http.request'.
         extras (dict): Additional values to add to the WSGI
             ``environ`` dictionary or the ASGI scope for the request
             (default: ``None``)
@@ -1322,7 +1333,8 @@ def simulate_post(app, path, **kwargs) -> _ResultBase:
         asgi_disconnect_ttl (int): The maximum number of seconds to wait
             since the request was initiated, before emitting an
             'http.disconnect' event when the app calls the
-            receive() function (default 300).
+            receive() function (default 300). Set to ``0`` to simulate an
+            immediate disconnection without first emitting 'http.request'.
         extras (dict): Additional values to add to the WSGI
             ``environ`` dictionary or the ASGI scope for the request
             (default: ``None``)
@@ -1426,7 +1438,8 @@ def simulate_put(app, path, **kwargs) -> _ResultBase:
         asgi_disconnect_ttl (int): The maximum number of seconds to wait
             since the request was initiated, before emitting an
             'http.disconnect' event when the app calls the
-            receive() function (default 300).
+            receive() function (default 300). Set to ``0`` to simulate an
+            immediate disconnection without first emitting 'http.request'.
         extras (dict): Additional values to add to the WSGI
             ``environ`` dictionary or the ASGI scope for the request
             (default: ``None``)
@@ -1512,7 +1525,8 @@ def simulate_options(app, path, **kwargs) -> _ResultBase:
         asgi_disconnect_ttl (int): The maximum number of seconds to wait
             since the request was initiated, before emitting an
             'http.disconnect' event when the app calls the
-            receive() function (default 300).
+            receive() function (default 300). Set to ``0`` to simulate an
+            immediate disconnection without first emitting 'http.request'.
         extras (dict): Additional values to add to the WSGI
             ``environ`` dictionary or the ASGI scope for the request
             (default: ``None``)
@@ -1607,7 +1621,8 @@ def simulate_patch(app, path, **kwargs) -> _ResultBase:
         asgi_disconnect_ttl (int): The maximum number of seconds to wait
             since the request was initiated, before emitting an
             'http.disconnect' event when the app calls the
-            receive() function (default 300).
+            receive() function (default 300). Set to ``0`` to simulate an
+            immediate disconnection without first emitting 'http.request'.
         extras (dict): Additional values to add to the WSGI
             ``environ`` dictionary or the ASGI scope for the request
             (default: ``None``)
@@ -1706,7 +1721,8 @@ def simulate_delete(app, path, **kwargs) -> _ResultBase:
         asgi_disconnect_ttl (int): The maximum number of seconds to wait
             since the request was initiated, before emitting an
             'http.disconnect' event when the app calls the
-            receive() function (default 300).
+            receive() function (default 300). Set to ``0`` to simulate an
+            immediate disconnection without first emitting 'http.request'.
         extras (dict): Additional values to add to the WSGI
             ``environ`` dictionary or the ASGI scope for the request
             (default: ``None``)
diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py
index 6a73151..46c853a 100644
--- a/falcon/testing/helpers.py
+++ b/falcon/testing/helpers.py
@@ -114,9 +114,12 @@ class ASGIRequestEventEmitter:
         chunk_size (int): The maximum number of bytes to include in
             a single http.request event (default 4096).
         disconnect_at (float): The Unix timestamp after which to begin
-            emitting http.disconnect events (default now + 30s). The
+            emitting ``'http.disconnect'`` events (default now + 30s). The
             value may be either an ``int`` or a ``float``, depending
-            on the precision required.
+            on the precision required. Setting `disconnect_at` to
+            ``0`` is treated as a special case, and will result in an
+            ``'http.disconnect'`` event being immediately emitted (rather than
+            first emitting an ``'http.request'`` event).
 
     Attributes:
         disconnected (bool): Returns ``True`` if the simulated client
@@ -131,7 +134,7 @@ class ASGIRequestEventEmitter:
         self,
         body: Union[str, bytes] = None,
         chunk_size: int = None,
-        disconnect_at: Union[int, float] = None
+        disconnect_at: Union[int, float] = None,
     ):
         if body is None:
             body = b''
@@ -148,6 +151,7 @@ class ASGIRequestEventEmitter:
 
         self._body = body  # type: Optional[memoryview]
         self._chunk_size = chunk_size
+        self._emit_empty_chunks = True
         self._disconnect_at = disconnect_at
         self._disconnected = False
         self._exhaust_body = True
@@ -177,9 +181,17 @@ class ASGIRequestEventEmitter:
         self._disconnected = True
 
     async def emit(self) -> Dict[str, Any]:
+        # NOTE(kgriffs): Special case: if we are immediately disconnected,
+        #   the first event should be 'http.disconnnect'
+        if self._disconnect_at == 0:
+            return {'type': EventType.HTTP_DISCONNECT}
+
         #
         # NOTE(kgriffs): Based on my reading of the ASGI spec, at least one
-        #   'http.request' event should be emitted before 'http.disconnect'.
+        #   'http.request' event should be emitted before 'http.disconnect'
+        #   for normal requests. However, the server may choose to
+        #   immediately abandon a connection for some reason, in which case
+        #   an 'http.request' event may never be sent.
         #
         #   See also: https://asgi.readthedocs.io/en/latest/specs/main.html#events
         #
@@ -194,33 +206,34 @@ class ASGIRequestEventEmitter:
 
         event = {'type': EventType.HTTP_REQUEST}  # type: Dict[str, Any]
 
-        # NOTE(kgriffs): Return a couple variations on empty chunks
-        #   every time, to ensure test coverage.
-        if not self._emitted_empty_chunk_a:
-            self._emitted_empty_chunk_a = True
-            event['more_body'] = True
-            return event
+        if self._emit_empty_chunks:
+            # NOTE(kgriffs): Return a couple variations on empty chunks
+            #   every time, to ensure test coverage.
+            if not self._emitted_empty_chunk_a:
+                self._emitted_empty_chunk_a = True
+                event['more_body'] = True
+                return event
+
+            if not self._emitted_empty_chunk_b:
+                self._emitted_empty_chunk_b = True
+                event['more_body'] = True
+                event['body'] = b''
+                return event
 
-        if not self._emitted_empty_chunk_b:
-            self._emitted_empty_chunk_b = True
-            event['more_body'] = True
-            event['body'] = b''
-            return event
+            # NOTE(kgriffs): Part of the time just return an
+            #   empty chunk to make sure the app handles that
+            #   correctly.
+            if self._toggle_branch('return_empty_chunk'):
+                event['more_body'] = True
 
-        # NOTE(kgriffs): Part of the time just return an
-        #   empty chunk to make sure the app handles that
-        #   correctly.
-        if self._toggle_branch('return_empty_chunk'):
-            event['more_body'] = True
-
-            # NOTE(kgriffs): Since ASGI specifies that
-            #   'body' is optional, we toggle whether
-            #   or not to explicitly set it to b'' to ensure
-            #   the app handles both correctly.
-            if self._toggle_branch('explicit_empty_body_1'):
-                event['body'] = b''
+                # NOTE(kgriffs): Since ASGI specifies that
+                #   'body' is optional, we toggle whether
+                #   or not to explicitly set it to b'' to ensure
+                #   the app handles both correctly.
+                if self._toggle_branch('explicit_empty_body_1'):
+                    event['body'] = b''
 
-            return event
+                return event
 
         chunk = self._body[:self._chunk_size]
         self._body = self._body[self._chunk_size:] or None
@@ -301,7 +314,7 @@ class ASGIResponseEventCollector:
         if not isinstance(event_type, str):
             raise TypeError('ASGI event type must be a Unicode string')
 
-        if event_type == 'http.response.start':
+        if event_type == EventType.HTTP_RESPONSE_START:
             for name, value in event.get('headers', []):
                 if not isinstance(name, bytes):
                     raise TypeError('ASGI header names must be byte strings')
@@ -319,7 +332,7 @@ class ASGIResponseEventCollector:
             if not isinstance(self.status, int):
                 raise TypeError('ASGI status must be an int')
 
-        elif event_type == 'http.response.body':
+        elif event_type == EventType.HTTP_RESPONSE_BODY:
             chunk = event.get('body', b'')
             if not isinstance(chunk, bytes):
                 raise TypeError('ASGI body content must be a byte string')
diff --git a/tests/asgi/test_boundedstream_asgi.py b/tests/asgi/test_boundedstream_asgi.py
index eac3c71..f70dec8 100644
--- a/tests/asgi/test_boundedstream_asgi.py
+++ b/tests/asgi/test_boundedstream_asgi.py
@@ -41,10 +41,15 @@ def test_read_all(body, extra_body, set_content_length):
 
     async def test_iteration():
         s = stream()
-        assert b''.join([chunk async for chunk in s]) == expected_body
+
+        chunks = [chunk async for chunk in s]
+        if not (expected_body or extra_body):
+            assert not chunks
+
+        assert b''.join(chunks) == expected_body
         assert await s.read() == b''
         assert await s.readall() == b''
-        assert [chunk async for chunk in s][0] == b''
+        assert not [chunk async for chunk in s]
         assert s.tell() == len(expected_body)
         assert s.eof
 
@@ -53,7 +58,7 @@ def test_read_all(body, extra_body, set_content_length):
         assert await s.readall() == expected_body
         assert await s.read() == b''
         assert await s.readall() == b''
-        assert [chunk async for chunk in s][0] == b''
+        assert not [chunk async for chunk in s]
         assert s.tell() == len(expected_body)
         assert s.eof
 
@@ -62,7 +67,7 @@ def test_read_all(body, extra_body, set_content_length):
         assert await s.read() == expected_body
         assert await s.readall() == b''
         assert await s.read() == b''
-        assert [chunk async for chunk in s][0] == b''
+        assert not [chunk async for chunk in s]
         assert s.tell() == len(expected_body)
         assert s.eof
 
@@ -97,7 +102,7 @@ def test_read_all(body, extra_body, set_content_length):
         assert await s.read(-1) == b''
         assert await s.readall() == b''
         assert await s.read() == b''
-        assert [chunk async for chunk in s][0] == b''
+        assert not [chunk async for chunk in s]
 
         assert await s.read(-2) == b''
 
diff --git a/tests/asgi/test_request_body_asgi.py b/tests/asgi/test_request_body_asgi.py
index 19967b0..01ea452 100644
--- a/tests/asgi/test_request_body_asgi.py
+++ b/tests/asgi/test_request_body_asgi.py
@@ -1,3 +1,5 @@
+import os
+
 import pytest
 
 import falcon
@@ -51,6 +53,27 @@ class TestRequestBody:
         assert resource.captured_req_body == expected_body.encode('utf-8')
         assert stream.tell() == expected_len
 
+    @pytest.mark.parametrize('body_length, content_length', [
+        (1, 0),
+        (2, 1),
+        (3, 2),
+        (100, None),
+        (100, 50),
+        (8192, 50),
+    ])
+    @pytest.mark.asyncio
+    async def test_content_length_smaller_than_body(self, body_length, content_length):
+        body_in = os.urandom(body_length)
+
+        scope = testing.create_scope(content_length=content_length)
+        req_event_emitter = testing.ASGIRequestEventEmitter(body=body_in)
+        req_event_emitter._emit_empty_chunks = False
+        first_event = await req_event_emitter.emit()
+        req = falcon.asgi.Request(scope, req_event_emitter, first_event=first_event)
+
+        body_out = await req.bounded_stream.read()
+        assert body_out == body_in[:content_length]
+
     def test_read_body(self, client, resource):
         client.app.add_route('/', resource)
         expected_body = testing.rand_string(SIZE_1_KB / 2, SIZE_1_KB)
diff --git a/tests/asgi/test_testing_asgi.py b/tests/asgi/test_testing_asgi.py
index 3fcaf74..98fa217 100644
--- a/tests/asgi/test_testing_asgi.py
+++ b/tests/asgi/test_testing_asgi.py
@@ -113,3 +113,10 @@ def test_cookies_jar():
     response_two = client.simulate_post('/jars', cookies=response_one.cookies)
 
     assert response_two.status == falcon.HTTP_200
+
+
+def test_immediate_disconnect():
+    client = testing.TestClient(_asgi_test_app.application)
+
+    with pytest.raises(ConnectionError):
+        client.simulate_get('/', asgi_disconnect_ttl=0)