Repository: falconry/falcon
Pull request #1845 (2020-12-29 00:02:34)

perf(ASGI): tune ASGI request-response cycle performance
- [x] Speed up ASGI scope validation (or make concessions and be less strict)
- [x] ~~Speed up `Response` headers (I won't have the time to complete this _right now_)~~
- [x] Avoid unnecessary function calls (where reasonable)
- [x] Avoid unnecessary awaits (where reasonable)
- [x] Speed up `Request.__init__()`
- [x] Restore 100% coverage

codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1845?src=pr&el=h1) Report
> Merging [#1845](https://codecov.io/gh/falconry/falcon/pull/1845?src=pr&el=desc) (bd657d8) into [master](https://codecov.io/gh/falconry/falcon/commit/78f21d57efab267c9794c45ca78f8b0e4192d4ac?el=desc) (78f21d5) will **decrease** coverage by `65.63%`.
> The diff coverage is `9.25%`.

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

```diff
@@             Coverage Diff              @@
##            master    #1845       +/-   ##
============================================
- Coverage   100.00%   34.36%   -65.64%     
============================================
  Files           54       55        +1     
  Lines         5154     5168       +14     
  Branches       831      838        +7     
============================================
- Hits          5154     1776     -3378     
- Misses           0     3352     +3352     
- Partials         0       40       +40     
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1845?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/asgi/app.py](https://codecov.io/gh/falconry/falcon/pull/1845/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvYXBwLnB5) | `20.70% <3.57%> (-79.30%)` | :arrow_down: |
| [falcon/asgi/\_asgi\_helpers.py](https://codecov.io/gh/falconry/falcon/pull/1845/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvX2FzZ2lfaGVscGVycy5weQ==) | `15.38% <15.38%> (ø)` | |
| [falcon/util/reader.py](https://codecov.io/gh/falconry/falcon/pull/1845/diff?src=pr&el=tree#diff-ZmFsY29uL3V0aWwvcmVhZGVyLnB5) | `12.62% <0.00%> (-87.38%)` | :arrow_down: |
| [falcon/asgi/stream.py](https://codecov.io/gh/falconry/falcon/pull/1845/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvc3RyZWFtLnB5) | `13.58% <0.00%> (-86.42%)` | :arrow_down: |
| [falcon/asgi/structures.py](https://codecov.io/gh/falconry/falcon/pull/1845/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvc3RydWN0dXJlcy5weQ==) | `14.58% <0.00%> (-85.42%)` | :arrow_down: |
| [falcon/middleware.py](https://codecov.io/gh/falconry/falcon/pull/1845/diff?src=pr&el=tree#diff-ZmFsY29uL21pZGRsZXdhcmUucHk=) | `14.89% <0.00%> (-85.11%)` | :arrow_down: |
| [falcon/asgi/reader.py](https://codecov.io/gh/falconry/falcon/pull/1845/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvcmVhZGVyLnB5) | `16.00% <0.00%> (-84.00%)` | :arrow_down: |
| [falcon/testing/helpers.py](https://codecov.io/gh/falconry/falcon/pull/1845/diff?src=pr&el=tree#diff-ZmFsY29uL3Rlc3RpbmcvaGVscGVycy5weQ==) | `18.63% <0.00%> (-81.37%)` | :arrow_down: |
| [falcon/asgi/ws.py](https://codecov.io/gh/falconry/falcon/pull/1845/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvd3MucHk=) | `22.66% <0.00%> (-77.34%)` | :arrow_down: |
| [falcon/asgi/request.py](https://codecov.io/gh/falconry/falcon/pull/1845/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvcmVxdWVzdC5weQ==) | `23.69% <0.00%> (-76.31%)` | :arrow_down: |
| ... and [39 more](https://codecov.io/gh/falconry/falcon/pull/1845/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1845?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/1845?src=pr&el=footer). Last update [78f21d5...bd657d8](https://codecov.io/gh/falconry/falcon/pull/1845?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

kgriffs: Some test cases and results regarding request header decoding (see my comment on the gist): https://gist.github.com/kgriffs/fa31baf256d81ef6e4bb1b401b4e3dae
kgriffs: I updated my gist with some additional findings: https://gist.github.com/kgriffs/fa31baf256d81ef6e4bb1b401b4e3dae
vytas7: I'm looking to attempt inlining `Response.render_body()` in order to shave off 1 `await`, but this should be otherwise ready to review.
This PR improves about performance about 10-15% compared to `master`.
And makes us faster than Starlette in a "Hello, world" type of benchmark in an isolated pseudo-async loop.
However, on real Uvicorn, Starlette is still somehow magically faster... :thinking: 

Patch:
diff --git a/falcon/asgi/_asgi_helpers.py b/falcon/asgi/_asgi_helpers.py
index 44f26ab..60b9b0e 100644
--- a/falcon/asgi/_asgi_helpers.py
+++ b/falcon/asgi/_asgi_helpers.py
@@ -14,6 +14,51 @@
 
 import inspect
 
+from falcon.errors import UnsupportedError, UnsupportedScopeError
+from falcon.util.misc import _lru_cache_safe
+
+
+@_lru_cache_safe(maxsize=16)
+def _validate_asgi_scope(scope_type, spec_version, http_version):
+    if scope_type == 'http':
+        spec_version = spec_version or '2.0'
+        if not spec_version.startswith('2.'):
+            raise UnsupportedScopeError(
+                f'The ASGI "http" scope version {spec_version} is not supported.'
+            )
+        if http_version not in {'1.0', '1.1', '2', '3'}:
+            raise UnsupportedError(
+                f'The ASGI "http" scope does not support HTTP version {http_version}.'
+            )
+        return spec_version
+
+    if scope_type == 'websocket':
+        spec_version = spec_version or '2.0'
+        if not spec_version.startswith('2.'):
+            raise UnsupportedScopeError(
+                'Only versions 2.x of the ASGI "websocket" scope are supported.'
+            )
+        if http_version not in {'1.1', '2', '3'}:
+            raise UnsupportedError(
+                f'The ASGI "websocket" scope does not support HTTP version {http_version}.'
+            )
+        return spec_version
+
+    if scope_type == 'lifespan':
+        spec_version = spec_version or '1.0'
+        if not spec_version.startswith('1.') and not spec_version.startswith('2.'):
+            raise UnsupportedScopeError(
+                'Only versions 1.x and 2.x of the ASGI "lifespan" scope are supported.'
+            )
+        return spec_version
+
+    # NOTE(kgriffs): According to the ASGI spec: "Applications should
+    #   actively reject any protocol that they do not understand with
+    #   an Exception (of any type)."
+    raise UnsupportedScopeError(
+        f'The ASGI "{scope_type}" scope type is not supported.'
+    )
+
 
 def _wrap_asgi_coroutine_func(asgi_impl):
     """Wrap an ASGI application in another coroutine.
diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py
index e74d226..2dbaf79 100644
--- a/falcon/asgi/app.py
+++ b/falcon/asgi/app.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2020 by Kurt Griffiths
+# Copyright 2019-2021 by Kurt Griffiths
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -24,11 +24,10 @@ from falcon.app_helpers import prepare_middleware
 from falcon.app_helpers import prepare_middleware_ws
 from falcon.asgi_spec import EventType
 from falcon.asgi_spec import WSCloseCode
+from falcon.constants import _UNSET
 from falcon.constants import MEDIA_JSON
 from falcon.errors import CompatibilityError
 from falcon.errors import HTTPBadRequest
-from falcon.errors import UnsupportedError
-from falcon.errors import UnsupportedScopeError
 from falcon.errors import WebSocketDisconnected
 from falcon.http_error import HTTPError
 from falcon.http_status import HTTPStatus
@@ -40,6 +39,7 @@ from falcon.util.sync import _should_wrap_non_coroutines
 from falcon.util.sync import _wrap_non_coroutine_unsafe
 from falcon.util.sync import get_running_loop
 from falcon.util.sync import wrap_sync_to_async
+from ._asgi_helpers import _validate_asgi_scope
 from ._asgi_helpers import _wrap_asgi_coroutine_func
 from .multipart import MultipartForm
 from .request import Request
@@ -283,113 +283,62 @@ class App(falcon.app.App):
 
     @_wrap_asgi_coroutine_func
     async def __call__(self, scope, receive, send):  # noqa: C901
+        # NOTE(kgriffs): The ASGI spec requires the 'type' key to be present.
+        scope_type = scope['type']
+
+        # PERF(kgriffs): This should usually be present, so use a
+        #   try..except
         try:
             asgi_info = scope['asgi']
-
-            # NOTE(kgriffs): We only check this here because
-            #   uvicorn does not explicitly set the 'asgi' key, which
-            #   would normally mean we should assume '2.0', but uvicorn
-            #   actually *does* support 3.0. But in that case, we will
-            #   end up in the except clause, below, and not raise an
-            #   error.
-            # PERF(kgriffs): This should usually be present, so use a
-            #   try..except
-            try:
-                version = asgi_info['version']
-            except KeyError:
-                # NOTE(kgriffs): According to the ASGI spec, "2.0" is
-                #   the default version.
-                version = '2.0'
-
-            if not version.startswith('3.'):
-                raise UnsupportedScopeError(
-                    f'Falcon requires ASGI version 3.x. Detected: {asgi_info}'
-                )
-
         except KeyError:
+            # NOTE(kgriffs): According to the ASGI spec, "2.0" is
+            #   the default version.
             asgi_info = scope['asgi'] = {'version': '2.0'}
 
-        # NOTE(kgriffs): The ASGI spec requires the 'type' key to be present.
-        scope_type = scope['type']
-        if scope_type != 'http':
-            if scope_type == 'lifespan':
-                try:
-                    spec_version = asgi_info['spec_version']
-                except KeyError:
-                    spec_version = '1.0'
-
-                if not spec_version.startswith('1.') and not spec_version.startswith('2.'):
-                    raise UnsupportedScopeError(
-                        'Only versions 1.x and 2.x of the ASGI "lifespan" scope are supported.'
-                    )
-
-                await self._call_lifespan_handlers(spec_version, scope, receive, send)
-                return
-
-            elif scope_type == 'websocket':
-                try:
-                    spec_version = asgi_info['spec_version']
-                except KeyError:
-                    spec_version = '2.0'
-
-                if not spec_version.startswith('2.'):
-                    raise UnsupportedScopeError(
-                        'Only versions 2.x of the ASGI "websocket" scope are supported.'
-                    )
+        try:
+            spec_version = asgi_info['spec_version']
+        except KeyError:
+            spec_version = None
 
-                try:
-                    http_version = scope['http_version']
-                except KeyError:
-                    http_version = '1.1'
+        try:
+            http_version = scope['http_version']
+        except KeyError:
+            http_version = '1.1'
 
-                if http_version not in {'1.1', '2', '3'}:
-                    raise UnsupportedError(
-                        f'The ASGI "websocket" scope does not support HTTP version {http_version}.'
-                    )
+        spec_version = _validate_asgi_scope(scope_type, spec_version, http_version)
 
+        if scope_type != 'http':
+            # PERF(vytas): Evaluate the potentially recurring WebSocket path
+            #   first (in contrast to one-shot lifespan events).
+            if scope_type == 'websocket':
                 await self._handle_websocket(spec_version, scope, receive, send)
                 return
 
-            # NOTE(kgriffs): According to the ASGI spec: "Applications should
-            #   actively reject any protocol that they do not understand with
-            #   an Exception (of any type)."
-            raise UnsupportedScopeError(
-                f'The ASGI "{scope_type}" scope type is not supported.'
-            )
-
-        # PERF(kgriffs): This is slighter faster than using dict.get()
-        # TODO(kgriffs): Use this to determine what features are supported by
-        #   the server (e.g., the headers key in the WebSocket Accept
-        #   response).
-        spec_version = asgi_info['spec_version'] if 'spec_version' in asgi_info else '2.0'
-
-        if not spec_version.startswith('2.'):
-            raise UnsupportedScopeError(
-                f'The ASGI http scope version {spec_version} is not supported.'
-            )
+            # NOTE(vytas): Else 'lifespan' -- other scope_type values have been
+            #   eliminated by _validate_asgi_scope at this point.
+            await self._call_lifespan_handlers(spec_version, scope, receive, send)
+            return
 
         # 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:
+        # PERF(vytas): Inline the value of EventType.HTTP_DISCONNECT in this
+        #   critical code path.
+        if first_event_type == '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
+        # PERF(vytas): Inline the value of EventType.HTTP_REQUEST in this
+        #   critical code path.
+        assert first_event_type == 'http.request'
 
         req = self._request_type(scope, receive, first_event=first_event, options=self.req_options)
         resp = self._response_type(options=self.resp_options)
 
-        if self.req_options.auto_parse_form_urlencoded:
-            raise UnsupportedError(
-                'The deprecated WSGI RequestOptions.auto_parse_form_urlencoded option '
-                'is not supported for ASGI apps. Please use Request.get_media() instead. '
-            )
-
         resource = None
         responder = None
         params = {}
@@ -476,7 +425,40 @@ class App(falcon.app.App):
         data = b''
 
         try:
-            data = await resp.render_body()
+            # PERF(vytas): inline Response.render_body() in this critical code
+            #   path in order to shave off an await.
+            text = resp.text
+            if text is None:
+                data = resp._data
+
+                if data is None and resp._media is not None:
+                    # NOTE(kgriffs): We use a special _UNSET singleton since
+                    #   None is ambiguous (the media handler might return None).
+                    if resp._media_rendered is _UNSET:
+                        if not resp.content_type:
+                            resp.content_type = resp.options.default_media_type
+
+                        handler, serialize_sync, _ = resp.options.media_handlers._resolve(
+                            resp.content_type,
+                            resp.options.default_media_type
+                        )
+
+                        if serialize_sync:
+                            resp._media_rendered = serialize_sync(resp._media)
+                        else:
+                            resp._media_rendered = await handler.serialize_async(
+                                resp._media,
+                                resp.content_type
+                            )
+
+                    data = resp._media_rendered
+            else:
+                try:
+                    # NOTE(kgriffs): Normally we expect text to be a string
+                    data = text.encode()
+                except AttributeError:
+                    # NOTE(kgriffs): Assume it was a bytes object already
+                    data = text
         except Exception as ex:
             if not await self._handle_exception(req, resp, ex, params):
                 raise
@@ -518,17 +500,25 @@ class App(falcon.app.App):
                 resp._headers['content-length'] = str(len(data)) if data else '0'
 
             await send({
-                'type': EventType.HTTP_RESPONSE_START,
+                # PERF(vytas): Inline the value of
+                #   EventType.HTTP_RESPONSE_START in this critical code path.
+                'type': 'http.response.start',
                 'status': resp_status,
                 'headers': resp._asgi_headers(default_media_type)
             })
 
             await send(_EVT_RESP_EOF)
-            self._schedule_callbacks(resp)
+
+            # PERF(vytas): Check resp._registered_callbacks directly to shave
+            #   off a function call since this is a hot/critical code path.
+            if resp._registered_callbacks:
+                self._schedule_callbacks(resp)
             return
 
-        sse_emitter = resp.sse
-        if sse_emitter:
+        # PERF(vytas): Operate directly on the resp private interface to reduce
+        #   overhead since this is a hot/critical code path.
+        if resp._sse:
+            sse_emitter = resp._sse
             if isasyncgenfunction(sse_emitter):
                 raise TypeError(
                     'Response.sse must be an async iterable. This can be obtained by '
@@ -553,7 +543,10 @@ class App(falcon.app.App):
                 'headers': resp._asgi_headers('text/event-stream')
             })
 
-            self._schedule_callbacks(resp)
+            # PERF(vytas): Check resp._registered_callbacks directly to shave
+            #   off a function call since this is a hot/critical code path.
+            if resp._registered_callbacks:
+                self._schedule_callbacks(resp)
 
             handler, _, _ = self.resp_options.media_handlers._resolve(
                 MEDIA_JSON, MEDIA_JSON, raise_not_found=False
@@ -597,17 +590,24 @@ class App(falcon.app.App):
             resp._headers['content-length'] = str(len(data))
 
             await send({
-                'type': EventType.HTTP_RESPONSE_START,
+                # PERF(vytas): Inline the value of
+                #   EventType.HTTP_RESPONSE_START in this critical code path.
+                'type': 'http.response.start',
                 'status': resp_status,
                 'headers': resp._asgi_headers(default_media_type)
             })
 
             await send({
-                'type': EventType.HTTP_RESPONSE_BODY,
+                # PERF(vytas): Inline the value of
+                #   EventType.HTTP_RESPONSE_BODY in this critical code path.
+                'type': 'http.response.body',
                 'body': data
             })
 
-            self._schedule_callbacks(resp)
+            # PERF(vytas): Check resp._registered_callbacks directly to shave
+            #   off a function call since this is a hot/critical code path.
+            if resp._registered_callbacks:
+                self._schedule_callbacks(resp)
             return
 
         stream = resp.stream
@@ -615,7 +615,9 @@ class App(falcon.app.App):
             resp._headers['content-length'] = '0'
 
         await send({
-            'type': EventType.HTTP_RESPONSE_START,
+            # PERF(vytas): Inline the value of
+            #   EventType.HTTP_RESPONSE_START in this critical code path.
+            'type': 'http.response.start',
             'status': resp_status,
             'headers': resp._asgi_headers(default_media_type)
         })
@@ -679,7 +681,11 @@ class App(falcon.app.App):
                 await stream.close()
 
         await send(_EVT_RESP_EOF)
-        self._schedule_callbacks(resp)
+
+        # PERF(vytas): Check resp._registered_callbacks directly to shave
+        #   off a function call since this is a hot/critical code path.
+        if resp._registered_callbacks:
+            self._schedule_callbacks(resp)
 
     def add_route(self, uri_template, resource, **kwargs):
         # NOTE(kgriffs): Inject an extra kwarg so that the compiled router
@@ -850,8 +856,10 @@ class App(falcon.app.App):
 
     def _schedule_callbacks(self, resp):
         callbacks = resp._registered_callbacks
-        if not callbacks:
-            return
+        # PERF(vytas): resp._registered_callbacks is already checked directly
+        # to shave off a function call since this is a hot/critical code path.
+        # if not callbacks:
+        #     return
 
         loop = get_running_loop()
 
@@ -865,6 +873,30 @@ class App(falcon.app.App):
         while True:
             event = await receive()
             if event['type'] == 'lifespan.startup':
+                # PERF(vytas): Perform these sanity checks upon application
+                #   startup, as opposed to repeating them every request.
+
+                # NOTE(vytas): If missing, 'asgi' is populated in __call__.
+                asgi_info = scope['asgi']
+                version = asgi_info.get('version', '2.0 (implicit)')
+                if not version.startswith('3.'):
+                    await send({
+                        'type': EventType.LIFESPAN_STARTUP_FAILED,
+                        'message': f'Falcon requires ASGI version 3.x. Detected: {version}.',
+                    })
+                    return
+
+                if self.req_options.auto_parse_form_urlencoded:
+                    await send({
+                        'type': EventType.LIFESPAN_STARTUP_FAILED,
+                        'message': (
+                            'The deprecated WSGI RequestOptions.auto_parse_form_urlencoded option '
+                            'is not supported for Falcon ASGI apps. '
+                            'Please use Request.get_media() instead. '
+                        ),
+                    })
+                    return
+
                 for handler in self._unprepared_middleware:
                     if hasattr(handler, 'process_startup'):
                         try:
diff --git a/falcon/asgi/request.py b/falcon/asgi/request.py
index 5c20855..8167f70 100644
--- a/falcon/asgi/request.py
+++ b/falcon/asgi/request.py
@@ -1,4 +1,4 @@
-# Copyright 2019 by Kurt Griffiths
+# Copyright 2019-2020 by Kurt Griffiths
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -347,14 +347,31 @@ class Request(request.Request):
 
     __slots__ = [
         '_asgi_headers',
-        '_asgi_server_cached',
-        '_cached_headers',
+        # '_asgi_server_cached',
+        # '_cached_headers',
         '_first_event',
         '_receive',
-        '_stream',
+        # '_stream',
         'scope',
     ]
 
+    # PERF(vytas): These boilerplates values will be shadowed when set on an
+    #   instance. Avoiding a statement per each of those values allows to speed
+    #   up __init__ substantially.
+    _asgi_server_cached = None
+    _cached_access_route = None
+    _cached_forwarded = None
+    _cached_forwarded_prefix = None
+    _cached_forwarded_uri = None
+    _cached_headers = None
+    _cached_prefix = None
+    _cached_relative_uri = None
+    _cached_uri = None
+    _media = _UNSET
+    _media_error = None
+    _stream = None
+    _wsgi_errors = None
+
     def __init__(self, scope, receive, first_event=None, options=None):
 
         # =====================================================================
@@ -384,24 +401,28 @@ class Request(request.Request):
                 req_headers[header_name] += b',' + header_value
 
         self._asgi_headers = req_headers
-        self._cached_headers = None
+        # PERF(vytas): Fall back to class variable(s) when unset.
+        # self._cached_headers = None
 
         # =====================================================================
         #  Misc.
         # =====================================================================
 
-        self._asgi_server_cached = None  # Lazy
+        # PERF(vytas): Fall back to class variable(s) when unset.
+        # self._asgi_server_cached = None  # Lazy
         self.scope = scope
         self.is_websocket = scope['type'] == 'websocket'
 
         self.options = options if options else request.RequestOptions()
 
-        self._wsgierrors = None
+        # PERF(vytas): Fall back to class variable(s) when unset.
+        # self._wsgierrors = None
         self.method = 'GET' if self.is_websocket else scope['method']
 
         self.uri_template = None
-        self._media = _UNSET
-        self._media_error = None
+        # PERF(vytas): Fall back to class variable(s) when unset.
+        # self._media = _UNSET
+        # self._media_error = None
 
         # TODO(kgriffs): ASGI does not specify whether 'path' may be empty,
         #   as was allowed for WSGI.
@@ -425,13 +446,14 @@ class Request(request.Request):
         else:
             self._params = {}
 
-        self._cached_access_route = None
-        self._cached_forwarded = None
-        self._cached_forwarded_prefix = None
-        self._cached_forwarded_uri = None
-        self._cached_prefix = None
-        self._cached_relative_uri = None
-        self._cached_uri = None
+        # PERF(vytas): Fall back to class variable(s) when unset.
+        # self._cached_access_route = None
+        # self._cached_forwarded = None
+        # self._cached_forwarded_prefix = None
+        # self._cached_forwarded_uri = None
+        # self._cached_prefix = None
+        # self._cached_relative_uri = None
+        # self._cached_uri = None
 
         if self.method == 'GET':
             # PERF(kgriffs): Normally we expect no Content-Type header, so
@@ -461,7 +483,8 @@ class Request(request.Request):
         #   (for example) by not confirming a 100 Continue request unless
         #   the app calls receive() to read the request body.
 
-        self._stream = None
+        # PERF(vytas): Fall back to class variable(s) when unset.
+        # self._stream = None
         self._receive = receive
         self._first_event = first_event
 
diff --git a/falcon/asgi/response.py b/falcon/asgi/response.py
index 73aa78c..e19a8f7 100644
--- a/falcon/asgi/response.py
+++ b/falcon/asgi/response.py
@@ -252,6 +252,8 @@ class Response(response.Response):
             none of these attributes are set, ``None`` is returned.
         """
 
+        # NOTE(vytas): The code below is also inlined in asgi.App.__call__.
+
         text = self.text
         if text is None:
             data = self._data


Test patch:
diff --git a/tests/asgi/test_response_media_asgi.py b/tests/asgi/test_response_media_asgi.py
index 88c0816..34ecebb 100644
--- a/tests/asgi/test_response_media_asgi.py
+++ b/tests/asgi/test_response_media_asgi.py
@@ -104,6 +104,27 @@ def test_msgpack(media_type):
     client.simulate_get('/')
 
 
+def test_custom_media_handler():
+
+    class PythonRepresentation(media.BaseHandler):
+        async def serialize_async(media, content_type):
+            return repr(media).encode()
+
+    class TestResource:
+        async def on_get(self, req, resp):
+            resp.content_type = 'text/x-python-repr'
+            resp.media = {'something': True}
+
+            body = await resp.render_body()
+
+            assert body == b"{'something': True}"
+
+    client = create_client(TestResource(), handlers={
+        'text/x-python-repr': PythonRepresentation(),
+    })
+    client.simulate_get('/')
+
+
 def test_unknown_media_type():
     class TestResource:
         async def on_get(self, req, resp):
@@ -184,7 +205,7 @@ def test_mimeparse_edgecases(monkeypatch_resolver):
     assert result.json == doc
 
 
-def runTest(test_fn):
+def run_test(test_fn):
     doc = {'something': True}
 
     class TestResource:
@@ -210,7 +231,7 @@ class TestRenderBodyPrecedence:
 
             assert await resp.render_body() == b'body'
 
-        runTest(test)
+        run_test(test)
 
     def test_body(self):
         async def test(resp):
@@ -221,7 +242,7 @@ class TestRenderBodyPrecedence:
 
             assert await resp.render_body() == b'body'
 
-        runTest(test)
+        run_test(test)
 
     def test_data(self):
         async def test(resp):
@@ -230,14 +251,23 @@ class TestRenderBodyPrecedence:
 
             assert await resp.render_body() == b'data'
 
-        runTest(test)
+        run_test(test)
+
+    def test_data_masquerading_as_text(self):
+        async def test(resp):
+            resp.text = b'data'
+            resp.media = ['media']
+
+            assert await resp.render_body() == b'data'
+
+        run_test(test)
 
     def test_media(self):
         async def test(resp):
             resp.media = ['media']
             assert json.loads((await resp.render_body()).decode('utf-8')) == ['media']
 
-        runTest(test)
+        run_test(test)
 
 
 def test_media_rendered_cached():
@@ -251,4 +281,4 @@ def test_media_rendered_cached():
         resp.media = 123
         assert first is not await resp.render_body()
 
-    runTest(test)
+    run_test(test)
diff --git a/tests/asgi/test_scheduled_callbacks.py b/tests/asgi/test_scheduled_callbacks.py
index c9a5d63..34b5230 100644
--- a/tests/asgi/test_scheduled_callbacks.py
+++ b/tests/asgi/test_scheduled_callbacks.py
@@ -1,8 +1,10 @@
+import asyncio
 from collections import Counter
 import time
 
 import pytest
 
+from falcon import runs_sync
 from falcon import testing
 from falcon.asgi import App
 
@@ -75,3 +77,70 @@ def test_multiple():
         client.simulate_put()
 
     assert 'coroutine' in str(exinfo.value)
+
+
+class SimpleCallback:
+    def __init__(self):
+        self.called = 0
+        self.event = asyncio.Event()
+
+    async def _call_me(self):
+        self.called += 1
+        self.event.set()
+
+    async def on_get(self, req, resp):
+        resp.content_type = 'text/plain'
+        resp.data = b'Hello, World!\n'
+        resp.schedule(self._call_me)
+
+    on_head = on_get
+
+    async def on_get_sse(self, req, resp):
+        async def nop_emitter():
+            yield None
+
+        resp.sse = nop_emitter()
+        resp.schedule(self._call_me)
+
+    async def on_get_stream(self, req, resp):
+        async def stream():
+            yield b'One\n'
+            yield b'Two\n'
+            yield b'Three\n'
+
+        resp.content_type = 'text/plain'
+        resp.stream = stream()
+        resp.schedule(self._call_me)
+
+
+@pytest.fixture()
+def simple_resource():
+    return SimpleCallback()
+
+
+@pytest.fixture()
+def callback_app(simple_resource):
+    app = App()
+    app.add_route('/', simple_resource)
+    app.add_route('/sse', simple_resource, suffix='sse')
+    app.add_route('/stream', simple_resource, suffix='stream')
+
+    return app
+
+
+@pytest.mark.parametrize('method,uri,expected', [
+    ('GET', '/', 'Hello, World!\n'),
+    ('HEAD', '/', ''),
+    ('GET', '/sse', ': ping\n\n'),
+    ('GET', '/stream', 'One\nTwo\nThree\n'),
+])
+@runs_sync
+async def test_callback(callback_app, simple_resource, method, uri, expected):
+    async with testing.ASGIConductor(callback_app) as conductor:
+        resp = await conductor.simulate_request(method, uri)
+        assert resp.status_code == 200
+        assert resp.text == expected
+
+        await asyncio.wait_for(simple_resource.event.wait(), 3.0)
+
+        assert simple_resource.called == 1
diff --git a/tests/asgi/test_scope.py b/tests/asgi/test_scope.py
index 8ee4ff4..300ccf2 100644
--- a/tests/asgi/test_scope.py
+++ b/tests/asgi/test_scope.py
@@ -5,7 +5,7 @@ import pytest
 import falcon
 from falcon import testing
 from falcon.asgi import App
-from falcon.errors import UnsupportedScopeError
+from falcon.errors import UnsupportedError, UnsupportedScopeError
 
 
 class CustomCookies:
@@ -24,6 +24,15 @@ def test_missing_asgi_version():
     assert resource.captured_req.scope['asgi']['version'] == '2.0'
 
 
+@pytest.mark.parametrize('http_version', ['0.9', '1.9', '4.0', '1337'])
+def test_unsupported_http_version(http_version):
+    scope = testing.create_scope()
+    scope['http_version'] = http_version
+
+    with pytest.raises(UnsupportedError):
+        _call_with_scope(scope)
+
+
 @pytest.mark.parametrize('version, supported', [
     ('3.0', True),
     ('3.1', True),
@@ -41,18 +50,44 @@ def test_missing_asgi_version():
     (None, False),
 ])
 def test_supported_asgi_version(version, supported):
-    scope = testing.create_scope()
-
-    if version:
-        scope['asgi']['version'] = version
-    else:
+    scope = {
+        'type': 'lifespan',
+        'asgi': {'spec_version': '2.0', 'version': version},
+    }
+    if version is None:
         del scope['asgi']['version']
 
-    if supported:
-        _call_with_scope(scope)
-    else:
-        with pytest.raises(UnsupportedScopeError):
-            _call_with_scope(scope)
+    app = App()
+
+    resource = testing.SimpleTestResourceAsync()
+    app.add_route('/', resource)
+
+    shutting_down = asyncio.Condition()
+    req_event_emitter = testing.ASGILifespanEventEmitter(shutting_down)
+    resp_event_collector = testing.ASGIResponseEventCollector()
+
+    async def task():
+        coro = asyncio.get_event_loop().create_task(
+            app(scope, req_event_emitter, resp_event_collector)
+        )
+
+        # NOTE(vytas): Yield to the lifespan task above.
+        await asyncio.sleep(0)
+
+        assert len(resp_event_collector.events) == 1
+        event = resp_event_collector.events[0]
+        if supported:
+            assert event['type'] == 'lifespan.startup.complete'
+        else:
+            assert event['type'] == 'lifespan.startup.failed'
+            assert event['message'].startswith('Falcon requires ASGI version 3.x')
+
+        async with shutting_down:
+            shutting_down.notify()
+
+        await coro
+
+    falcon.async_to_sync(task)
 
 
 @pytest.mark.parametrize('scope_type', ['tubes', 'http3', 'htt'])
diff --git a/tests/asgi/test_sse.py b/tests/asgi/test_sse.py
index 9719a3f..9e68ef0 100644
--- a/tests/asgi/test_sse.py
+++ b/tests/asgi/test_sse.py
@@ -22,6 +22,11 @@ def test_no_events():
             self._called = True
             resp.sse = Emitter()
 
+            # NOTE(vytas): Test explicitly referencing property since it is
+            # normally optimized away by operating directly on the private
+            # attribute in hot App.__call__ code paths.
+            assert resp.sse is not None
+
     resource = SomeResource()
 
     app = App()
@@ -304,7 +309,3 @@ def test_non_iterable():
 
     with pytest.raises(TypeError):
         client.simulate_get()
-
-
-# TODO: Test with uvicorn
-# TODO: Test in browser with JavaScript
diff --git a/tests/test_query_params.py b/tests/test_query_params.py
index 67f78fa..ad9f1fb 100644
--- a/tests/test_query_params.py
+++ b/tests/test_query_params.py
@@ -5,7 +5,7 @@ from uuid import UUID
 import pytest
 
 import falcon
-from falcon.errors import HTTPInvalidParam, UnsupportedError
+from falcon.errors import HTTPInvalidParam
 import falcon.testing as testing
 
 from _util import create_app  # NOQA
@@ -986,8 +986,9 @@ class TestPostQueryParams:
         app.add_route('/', resource)
         app.req_options.auto_parse_form_urlencoded = True
 
-        with pytest.raises(UnsupportedError):
+        with pytest.raises(RuntimeError) as exc_info:
             testing.simulate_get(app, '/')
+        assert 'RequestOptions.auto_parse_form_urlencoded' in exc_info.value.args[0]
 
 
 @pytest.mark.parametrize('asgi', [True, False])