Repository: falconry/falcon (2024-08-20 ~ 2024-10-05)

problem_statement: Unintended sharing of `MultipartParseOptions._DEFAULT_HANDLERS` between instances
I think we should initialize `media_handlers` in `MultipartParseOptions.__init__()` with a copy `MultipartParseOptions._DEFAULT_HANDLERS`, not just assign reference:
```python
>>> from falcon.media.multipart import MultipartParseOptions
>>> mo1 = MultipartParseOptions()
>>> mo2 = MultipartParseOptions()
>>> sorted(mo1.media_handlers)
['application/json', 'application/x-www-form-urlencoded']
>>> mo1.media_handlers.pop('application/x-www-form-urlencoded')
<falcon.media.urlencoded.URLEncodedFormHandler object at 0x7dce2c618f50>
>>> mo1.media_handlers
{'application/json': <falcon.media.json.JSONHandler object at 0x7dce2c7fa990>}
>>> mo2.media_handlers
{'application/json': <falcon.media.json.JSONHandler object at 0x7dce2c7fa990>}
>>> MultipartParseOptions._DEFAULT_HANDLERS
{'application/json': <falcon.media.json.JSONHandler object at 0x7dce2c7fa990>}
```

date: 2024-09-06
patch: diff --git a/docs/_newsfragments/2293.bugfix.rst b/docs/_newsfragments/2293.bugfix.rst
new file mode 100644
index 00000000..de4c6ba4
--- /dev/null
+++ b/docs/_newsfragments/2293.bugfix.rst
@@ -0,0 +1,10 @@
+Customizing
+:attr:`MultipartParseOptions.media_handlers
+<falcon.media.multipart.MultipartParseOptions.media_handlers>` could previously
+lead to unintentionally modifying a shared class variable.
+This has been fixed, and the
+:attr:`~falcon.media.multipart.MultipartParseOptions.media_handlers` attribute
+is now initialized to a fresh copy of handlers for every instance of
+:class:`~falcon.media.multipart.MultipartParseOptions`. To that end, a proper
+:meth:`~falcon.media.Handlers.copy` method has been implemented for the media
+:class:`~falcon.media.Handlers` class.
diff --git a/falcon/media/handlers.py b/falcon/media/handlers.py
index e37d5e3b..4eeaf4f7 100644
--- a/falcon/media/handlers.py
+++ b/falcon/media/handlers.py
@@ -164,6 +164,22 @@ class Handlers(UserDict):
 
         return cast(ResolverMethod, resolve)
 
+    def copy(self) -> Handlers:
+        """Create a shallow copy of this instance of handlers.
+
+        The resulting copy contains the same keys and values, but it can be
+        customized separately without affecting the original object.
+
+        Returns:
+            A shallow copy of handlers.
+
+        .. versionadded:: 4.0
+        """
+        # NOTE(vytas): In the unlikely case we are dealing with a subclass,
+        #   return the matching type.
+        handlers_cls = type(self)
+        return handlers_cls(self.data)
+
     @deprecation.deprecated(
         'This undocumented method is no longer supported as part of the public '
         'interface and will be removed in a future release.'
diff --git a/falcon/media/multipart.py b/falcon/media/multipart.py
index 17a99026..340a5aea 100644
--- a/falcon/media/multipart.py
+++ b/falcon/media/multipart.py
@@ -610,4 +610,7 @@ class MultipartParseOptions:
         self.max_body_part_buffer_size = 1024 * 1024
         self.max_body_part_count = 64
         self.max_body_part_headers_size = 8192
-        self.media_handlers = self._DEFAULT_HANDLERS
+        # NOTE(myusko,vytas): Here we create a copy of _DEFAULT_HANDLERS in
+        #   order to prevent the modification of the class variable whenever
+        #   parse_options.media_handlers are customized.
+        self.media_handlers = self._DEFAULT_HANDLERS.copy()

test_patch: diff --git a/tests/test_media_multipart.py b/tests/test_media_multipart.py
index 31c7bbff..7c408e32 100644
--- a/tests/test_media_multipart.py
+++ b/tests/test_media_multipart.py
@@ -8,6 +8,7 @@ import pytest
 import falcon
 from falcon import media
 from falcon import testing
+from falcon.media.multipart import MultipartParseOptions
 from falcon.util import BufferedReader
 
 try:
@@ -849,3 +850,14 @@ def test_deserialize_custom_media(custom_client):
 
     assert resp.status_code == 200
     assert resp.json == ['', '0x48']
+
+
+def test_multipart_parse_options_default_handlers_unique():
+    parse_options_one = MultipartParseOptions()
+    parse_options_two = MultipartParseOptions()
+
+    parse_options_one.media_handlers.pop(falcon.MEDIA_JSON)
+
+    assert parse_options_one.media_handlers is not parse_options_two.media_handlers
+    assert len(parse_options_one.media_handlers) == 1
+    assert len(parse_options_two.media_handlers) >= 2


----

problem_statement: Improve cors middleware to handle cases where allow header is not set by options responder
See discussion at https://github.com/falconry/falcon/pull/2304#pullrequestreview-2261582271

Also allow cors in static routes and sinks (??)

date: 2024-08-30
patch: diff --git a/docs/_newsfragments/2325.newandimproved.rst b/docs/_newsfragments/2325.newandimproved.rst
new file mode 100644
index 00000000..62a61b81
--- /dev/null
+++ b/docs/_newsfragments/2325.newandimproved.rst
@@ -0,0 +1,4 @@
+The :class:`~CORSMiddleware` now properly handles the missing ``Allow``
+header case, by denying the preflight CORS request.
+The static resource has been updated to properly support CORS request,
+by allowing GET requests.
diff --git a/falcon/app.py b/falcon/app.py
index 88ab0e55..71db45c2 100644
--- a/falcon/app.py
+++ b/falcon/app.py
@@ -759,6 +759,15 @@ class App:
         impractical. For example, you might use a sink to create a smart
         proxy that forwards requests to one or more backend services.
 
+        Note:
+            To support CORS preflight requests when using the default CORS middleware,
+            either by setting ``App.cors_enable=True`` or by adding the
+            :class:`~.CORSMiddleware` to the ``App.middleware``, the sink should
+            set the ``Allow`` header in the request to the allowed
+            method values when serving an ``OPTIONS`` request. If the ``Allow`` header
+            is missing from the response, the default CORS middleware will deny the
+            preflight request.
+
         Args:
             sink (callable): A callable taking the form ``func(req, resp, **kwargs)``.
 
diff --git a/falcon/middleware.py b/falcon/middleware.py
index 5772e16c..d457a44b 100644
--- a/falcon/middleware.py
+++ b/falcon/middleware.py
@@ -17,6 +17,15 @@ class CORSMiddleware(object):
     * https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
     * https://www.w3.org/TR/cors/#resource-processing-model
 
+    Note:
+        Falcon will automatically add OPTIONS responders if they are missing from the
+        responder instances added to the routes. When providing a custom ``on_options``
+        method, the ``Allow`` headers in the response should be set to the allowed
+        method values. If the ``Allow`` header is missing from the response,
+        this middleware will deny the preflight request.
+
+        This is also valid when using a sink function.
+
     Keyword Arguments:
         allow_origins (Union[str, Iterable[str]]): List of origins to allow (case
             sensitive). The string ``'*'`` acts as a wildcard, matching every origin.
@@ -120,9 +129,17 @@ class CORSMiddleware(object):
                 'Access-Control-Request-Headers', default='*'
             )
 
-            resp.set_header('Access-Control-Allow-Methods', allow)
-            resp.set_header('Access-Control-Allow-Headers', allow_headers)
-            resp.set_header('Access-Control-Max-Age', '86400')  # 24 hours
+            if allow is None:
+                # there is no allow set, remove all access control headers
+                resp.delete_header('Access-Control-Allow-Methods')
+                resp.delete_header('Access-Control-Allow-Headers')
+                resp.delete_header('Access-Control-Max-Age')
+                resp.delete_header('Access-Control-Expose-Headers')
+                resp.delete_header('Access-Control-Allow-Origin')
+            else:
+                resp.set_header('Access-Control-Allow-Methods', allow)
+                resp.set_header('Access-Control-Allow-Headers', allow_headers)
+                resp.set_header('Access-Control-Max-Age', '86400')  # 24 hours
 
     async def process_response_async(self, *args: Any) -> None:
         self.process_response(*args)
diff --git a/falcon/routing/static.py b/falcon/routing/static.py
index d07af421..cc2a5ab9 100644
--- a/falcon/routing/static.py
+++ b/falcon/routing/static.py
@@ -183,6 +183,12 @@ class StaticRoute:
     def __call__(self, req: Request, resp: Response, **kw: Any) -> None:
         """Resource responder for this route."""
         assert not kw
+        if req.method == 'OPTIONS':
+            # it's likely a CORS request. Set the allow header to the appropriate value.
+            resp.set_header('Allow', 'GET')
+            resp.set_header('Content-Length', '0')
+            return
+
         without_prefix = req.path[len(self._prefix) :]
 
         # NOTE(kgriffs): Check surrounding whitespace and strip trailing
@@ -247,9 +253,9 @@ class StaticRouteAsync(StaticRoute):
 
     async def __call__(self, req: asgi.Request, resp: asgi.Response, **kw: Any) -> None:  # type: ignore[override]
         super().__call__(req, resp, **kw)
-
-        # NOTE(kgriffs): Fixup resp.stream so that it is non-blocking
-        resp.stream = _AsyncFileReader(resp.stream)
+        if resp.stream is not None:  # None when in an option request
+            # NOTE(kgriffs): Fixup resp.stream so that it is non-blocking
+            resp.stream = _AsyncFileReader(resp.stream)  # type: ignore[assignment,arg-type]
 
 
 class _AsyncFileReader:
@@ -259,8 +265,8 @@ class _AsyncFileReader:
         self._file = file
         self._loop = asyncio.get_running_loop()
 
-    async def read(self, size=-1):
+    async def read(self, size: int = -1) -> bytes:
         return await self._loop.run_in_executor(None, partial(self._file.read, size))
 
-    async def close(self):
+    async def close(self) -> None:
         await self._loop.run_in_executor(None, self._file.close)

test_patch: diff --git a/tests/test_cors_middleware.py b/tests/test_cors_middleware.py
index 244d2239..9aff6abf 100644
--- a/tests/test_cors_middleware.py
+++ b/tests/test_cors_middleware.py
@@ -1,3 +1,5 @@
+from pathlib import Path
+
 import pytest
 
 import falcon
@@ -28,6 +30,12 @@ class CORSHeaderResource:
         resp.text = "I'm a CORS test response"
 
 
+class CORSOptionsResource:
+    def on_options(self, req, resp):
+        # No allow header set
+        resp.set_header('Content-Length', '0')
+
+
 class TestCorsMiddleware:
     def test_disabled_cors_should_not_add_any_extra_headers(self, client):
         client.app.add_route('/', CORSHeaderResource())
@@ -80,6 +88,21 @@ class TestCorsMiddleware:
             result.headers['Access-Control-Max-Age'] == '86400'
         )  # 24 hours in seconds
 
+    def test_enabled_cors_handles_preflight_custom_option(self, cors_client):
+        cors_client.app.add_route('/', CORSOptionsResource())
+        result = cors_client.simulate_options(
+            headers=(
+                ('Origin', 'localhost'),
+                ('Access-Control-Request-Method', 'GET'),
+                ('Access-Control-Request-Headers', 'X-PINGOTHER, Content-Type'),
+            )
+        )
+        assert 'Access-Control-Allow-Methods' not in result.headers
+        assert 'Access-Control-Allow-Headers' not in result.headers
+        assert 'Access-Control-Max-Age' not in result.headers
+        assert 'Access-Control-Expose-Headers' not in result.headers
+        assert 'Access-Control-Allow-Origin' not in result.headers
+
     def test_enabled_cors_handles_preflighting_no_headers_in_req(self, cors_client):
         cors_client.app.add_route('/', CORSHeaderResource())
         result = cors_client.simulate_options(
@@ -94,6 +117,50 @@ class TestCorsMiddleware:
             result.headers['Access-Control-Max-Age'] == '86400'
         )  # 24 hours in seconds
 
+    def test_enabled_cors_static_route(self, cors_client):
+        cors_client.app.add_static_route('/static', Path(__file__).parent)
+        result = cors_client.simulate_options(
+            f'/static/{Path(__file__).name}',
+            headers=(
+                ('Origin', 'localhost'),
+                ('Access-Control-Request-Method', 'GET'),
+            ),
+        )
+
+        assert result.headers['Access-Control-Allow-Methods'] == 'GET'
+        assert result.headers['Access-Control-Allow-Headers'] == '*'
+        assert result.headers['Access-Control-Max-Age'] == '86400'
+        assert result.headers['Access-Control-Allow-Origin'] == '*'
+
+    @pytest.mark.parametrize('support_options', [True, False])
+    def test_enabled_cors_sink_route(self, cors_client, support_options):
+        def my_sink(req, resp):
+            if req.method == 'OPTIONS' and support_options:
+                resp.set_header('ALLOW', 'GET')
+            else:
+                resp.text = 'my sink'
+
+        cors_client.app.add_sink(my_sink, '/sink')
+        result = cors_client.simulate_options(
+            '/sink/123',
+            headers=(
+                ('Origin', 'localhost'),
+                ('Access-Control-Request-Method', 'GET'),
+            ),
+        )
+
+        if support_options:
+            assert result.headers['Access-Control-Allow-Methods'] == 'GET'
+            assert result.headers['Access-Control-Allow-Headers'] == '*'
+            assert result.headers['Access-Control-Max-Age'] == '86400'
+            assert result.headers['Access-Control-Allow-Origin'] == '*'
+        else:
+            assert 'Access-Control-Allow-Methods' not in result.headers
+            assert 'Access-Control-Allow-Headers' not in result.headers
+            assert 'Access-Control-Max-Age' not in result.headers
+            assert 'Access-Control-Expose-Headers' not in result.headers
+            assert 'Access-Control-Allow-Origin' not in result.headers
+
 
 @pytest.fixture(scope='function')
 def make_cors_client(asgi, util):
diff --git a/tests/test_static.py b/tests/test_static.py
index cfeffc3b..1b38d2e6 100644
--- a/tests/test_static.py
+++ b/tests/test_static.py
@@ -617,3 +617,19 @@ def test_file_closed(client, patch_open):
 
     assert patch_open.current_file is not None
     assert patch_open.current_file.closed
+
+
+def test_options_request(util, asgi, patch_open):
+    patch_open()
+    app = util.create_app(asgi, cors_enable=True)
+    app.add_static_route('/static', '/var/www/statics')
+    client = testing.TestClient(app)
+
+    resp = client.simulate_options(
+        path='/static/foo/bar.txt',
+        headers={'Origin': 'localhost', 'Access-Control-Request-Method': 'GET'},
+    )
+    assert resp.status_code == 200
+    assert resp.text == ''
+    assert int(resp.headers['Content-Length']) == 0
+    assert resp.headers['Access-Control-Allow-Methods'] == 'GET'


----

problem_statement: Remove all deprecated behaviors and shims from 3.x
Grep the code for "deprecated", etc. and remove relevant code/interfaces. Be sure to include news fragments for each change.

date: 2024-08-20
patch: diff --git a/.gitignore b/.gitignore
index e8b6d5f7..a367aef0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,6 +34,7 @@ pip-log.txt
 .ecosystem
 .tox
 .pytest_cache
+downloaded_files/
 geckodriver.log
 htmlcov
 nosetests.xml
diff --git a/README.rst b/README.rst
index 549e6cdd..bd83f991 100644
--- a/README.rst
+++ b/README.rst
@@ -225,7 +225,7 @@ For your convenience, wheels containing pre-compiled binaries are available
 from PyPI for the majority of common platforms. Even if a binary build for your
 platform of choice is not available, ``pip`` will pick a pure-Python wheel.
 You can also cythonize Falcon for your environment; see our
-`Installation docs <https://falcon.readthedocs.io/en/stable/user/install.html>`__.
+`Installation docs <https://falcon.readthedocs.io/en/stable/user/install.html>`__
 for more information on this and other advanced options.
 
 Dependencies
diff --git a/docs/Makefile b/docs/Makefile
index e85440e4..278a71c7 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -1,177 +1,19 @@
-# Makefile for Sphinx documentation
-#
+# Makefile for Sphinx documentation.
 
-# You can set these variables from the command line.
-SPHINXOPTS    =
-SPHINXBUILD   = sphinx-build
-PAPER         =
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS    ?=
+SPHINXBUILD   ?= sphinx-build
+SOURCEDIR     = .
 BUILDDIR      = _build
 
-# User-friendly check for sphinx-build
-ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
-$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
-endif
-
-# Internal variables.
-PAPEROPT_a4     = -D latex_paper_size=a4
-PAPEROPT_letter = -D latex_paper_size=letter
-ALLSPHINXOPTS   = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
-# the i18n builder cannot share the environment and doctrees with the others
-I18NSPHINXOPTS  = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
-
-.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
-
+# Put it first so that "make" without argument is like "make help".
 help:
-	@echo "Please use \`make <target>' where <target> is one of"
-	@echo "  html       to make standalone HTML files"
-	@echo "  dirhtml    to make HTML files named index.html in directories"
-	@echo "  singlehtml to make a single large HTML file"
-	@echo "  pickle     to make pickle files"
-	@echo "  json       to make JSON files"
-	@echo "  htmlhelp   to make HTML files and a HTML help project"
-	@echo "  qthelp     to make HTML files and a qthelp project"
-	@echo "  devhelp    to make HTML files and a Devhelp project"
-	@echo "  epub       to make an epub"
-	@echo "  latex      to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
-	@echo "  latexpdf   to make LaTeX files and run them through pdflatex"
-	@echo "  latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
-	@echo "  text       to make text files"
-	@echo "  man        to make manual pages"
-	@echo "  texinfo    to make Texinfo files"
-	@echo "  info       to make Texinfo files and run them through makeinfo"
-	@echo "  gettext    to make PO message catalogs"
-	@echo "  changes    to make an overview of all changed/added/deprecated items"
-	@echo "  xml        to make Docutils-native XML files"
-	@echo "  pseudoxml  to make pseudoxml-XML files for display purposes"
-	@echo "  linkcheck  to check all external links for integrity"
-	@echo "  doctest    to run all doctests embedded in the documentation (if enabled)"
-
-clean:
-	rm -rf $(BUILDDIR)/*
-
-html:
-	$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
-	@echo
-	@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
-
-dirhtml:
-	$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
-	@echo
-	@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
-
-singlehtml:
-	$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
-	@echo
-	@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
-
-pickle:
-	$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
-	@echo
-	@echo "Build finished; now you can process the pickle files."
-
-json:
-	$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
-	@echo
-	@echo "Build finished; now you can process the JSON files."
-
-htmlhelp:
-	$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
-	@echo
-	@echo "Build finished; now you can run HTML Help Workshop with the" \
-	      ".hhp project file in $(BUILDDIR)/htmlhelp."
-
-qthelp:
-	$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
-	@echo
-	@echo "Build finished; now you can run "qcollectiongenerator" with the" \
-	      ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
-	@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Falcon.qhcp"
-	@echo "To view the help file:"
-	@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Falcon.qhc"
-
-devhelp:
-	$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
-	@echo
-	@echo "Build finished."
-	@echo "To view the help file:"
-	@echo "# mkdir -p $$HOME/.local/share/devhelp/Falcon"
-	@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Falcon"
-	@echo "# devhelp"
-
-epub:
-	$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
-	@echo
-	@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
-
-latex:
-	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
-	@echo
-	@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
-	@echo "Run \`make' in that directory to run these through (pdf)latex" \
-	      "(use \`make latexpdf' here to do that automatically)."
-
-latexpdf:
-	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
-	@echo "Running LaTeX files through pdflatex..."
-	$(MAKE) -C $(BUILDDIR)/latex all-pdf
-	@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
-
-latexpdfja:
-	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
-	@echo "Running LaTeX files through platex and dvipdfmx..."
-	$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
-	@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
-
-text:
-	$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
-	@echo
-	@echo "Build finished. The text files are in $(BUILDDIR)/text."
-
-man:
-	$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
-	@echo
-	@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
-
-texinfo:
-	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
-	@echo
-	@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
-	@echo "Run \`make' in that directory to run these through makeinfo" \
-	      "(use \`make info' here to do that automatically)."
-
-info:
-	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
-	@echo "Running Texinfo files through makeinfo..."
-	make -C $(BUILDDIR)/texinfo info
-	@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
-
-gettext:
-	$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
-	@echo
-	@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
-
-changes:
-	$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
-	@echo
-	@echo "The overview file is in $(BUILDDIR)/changes."
-
-linkcheck:
-	$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
-	@echo
-	@echo "Link check complete; look for any errors in the above output " \
-	      "or in $(BUILDDIR)/linkcheck/output.txt."
-
-doctest:
-	$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
-	@echo "Testing of doctests in the sources finished, look at the " \
-	      "results in $(BUILDDIR)/doctest/output.txt."
+	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
 
-xml:
-	$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
-	@echo
-	@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
+.PHONY: help Makefile
 
-pseudoxml:
-	$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
-	@echo
-	@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/_newsfragments/1853.breakingchange.rst b/docs/_newsfragments/1853.breakingchange.rst
index e6183c03..9333b010 100644
--- a/docs/_newsfragments/1853.breakingchange.rst
+++ b/docs/_newsfragments/1853.breakingchange.rst
@@ -1,3 +1,60 @@
-The deprecated ``has_representation()`` method for :class:`~falcon.HTTPError` was
-removed, along with the ``NoRepresentation`` and ``OptionalRepresentation``
-classes.
+A number of previously deprecated methods, attributes and classes have now been
+removed:
+
+* In Falcon 3.0, the use of positional arguments was deprecated for the
+  optional initializer parameters of :class:`falcon.HTTPError` and its
+  subclasses.
+
+  We have now redefined these optional arguments as keyword-only, so passing
+  them as positional arguments will result in a :class:`TypeError`:
+
+  >>> import falcon
+  >>> falcon.HTTPForbidden('AccessDenied')
+  Traceback (most recent call last):
+    <...>
+  TypeError: HTTPForbidden.__init__() takes 1 positional argument but 2 were given
+  >>> falcon.HTTPForbidden('AccessDenied', 'No write access')
+  Traceback (most recent call last):
+    <...>
+  TypeError: HTTPForbidden.__init__() takes 1 positional argument but 3 were given
+
+  Instead, simply pass these parameters as keyword arguments:
+
+  >>> import falcon
+  >>> falcon.HTTPForbidden(title='AccessDenied')
+  <HTTPForbidden: 403 Forbidden>
+  >>> falcon.HTTPForbidden(title='AccessDenied', description='No write access')
+  <HTTPForbidden: 403 Forbidden>
+
+* The ``falcon-print-routes`` command-line utility is no longer supported;
+  ``falcon-inspect-app`` is a direct replacement.
+
+* A deprecated utility function, ``falcon.get_http_status()``, was removed.
+  Please use :meth:`falcon.code_to_http_status` instead.
+
+* A deprecated routing utility, ``compile_uri_template()``, was removed.
+  This function was only employed in the early versions of the framework, and
+  is expected to have been fully supplanted by the
+  :class:`~falcon.routing.CompiledRouter`. In a pinch, you can simply copy its
+  implementation from the Falcon 3.x source tree into your application.
+
+* The deprecated ``Response.add_link()`` method was removed; please use
+  :meth:`Response.append_link <falcon.Response.append_link>` instead.
+
+* The deprecated ``has_representation()`` method for :class:`~falcon.HTTPError`
+  was removed, along with the ``NoRepresentation`` and
+  ``OptionalRepresentation`` classes.
+
+* An undocumented, deprecated public method ``find_by_media_type()`` of
+  :class:`media.Handlers <falcon.media.Handlers>` was removed.
+  Apart from configuring handlers for Internet media types, the rest of
+  :class:`~falcon.media.Handlers` is only meant to be used internally by the
+  framework (unless documented otherwise).
+
+* Previously, the ``json`` module could be imported via ``falcon.util``.
+  This deprecated alias was removed; please import ``json`` directly from the
+  :mod:`standard library <json>`, or another third-party JSON library of
+  choice.
+
+We decided, on the other hand, to keep the deprecated :class:`falcon.API` alias
+until Falcon 5.0.
diff --git a/docs/_newsfragments/2090.breakingchange.rst b/docs/_newsfragments/2090.breakingchange.rst
index 9c5a677f..6e16920c 100644
--- a/docs/_newsfragments/2090.breakingchange.rst
+++ b/docs/_newsfragments/2090.breakingchange.rst
@@ -1,4 +1,5 @@
 The deprecated ``api_helpers`` was removed in favor of the ``app_helpers``
 module. In addition, the deprecated ``body``
-attributes for the :class:`~falcon.HTTPResponse`,
-:class:`~falcon.asgi.HTTPResponse`, and :class:`~falcon.HTTPStatus` classes.
+attributes for the :class:`~falcon.Response`,
+:class:`asgi.Response <falcon.asgi.Response>`,
+and :class:`~falcon.HTTPStatus` classes.
diff --git a/docs/_newsfragments/2253.misc.rst b/docs/_newsfragments/2253.misc.rst
index 256f4a73..178cc549 100644
--- a/docs/_newsfragments/2253.misc.rst
+++ b/docs/_newsfragments/2253.misc.rst
@@ -1,3 +1,3 @@
 The :ref:`utility functions <util>` ``create_task()`` and
 ``get_running_loop()`` are now deprecated in favor of their standard library
-counterparts, :func:`asyncio.create_task` and `:func:`asyncio.get_running_loop`.
+counterparts, :func:`asyncio.create_task` and :func:`asyncio.get_running_loop`.
diff --git a/docs/_newsfragments/2292.bugfix.rst b/docs/_newsfragments/2292.bugfix.rst
new file mode 100644
index 00000000..9d7a0d4a
--- /dev/null
+++ b/docs/_newsfragments/2292.bugfix.rst
@@ -0,0 +1,16 @@
+Falcon will now raise an instance of
+:class:`~falcon.errors.WebSocketDisconnected` from the :class:`OSError` that
+the ASGI server signals in the case of a disconnected client (as per
+the `ASGI HTTP & WebSocket protocol
+<https://asgi.readthedocs.io/en/latest/specs/www.html#id2>`__ version ``2.4``).
+It is worth noting though that Falcon's
+:ref:`built-in receive buffer <ws_lost_connection>` normally detects the
+``websocket.disconnect`` event itself prior the potentially failing attempt to
+``send()``.
+
+Disabling this built-in receive buffer (by setting
+:attr:`~falcon.asgi.WebSocketOptions.max_receive_queue` to ``0``) was also
+found to interfere with receiving ASGI WebSocket messages in an unexpected
+way. The issue has been fixed so that setting this option to ``0`` now properly
+bypasses the buffer altogether, and extensive test coverage has been added for
+validating this scenario.
diff --git a/docs/_static/custom-icons.js b/docs/_static/custom-icons.js
new file mode 100644
index 00000000..e1fe2f11
--- /dev/null
+++ b/docs/_static/custom-icons.js
@@ -0,0 +1,34 @@
+/*******************************************************************************
+ * Set a custom icon for pypi as it's not available in the fa built-in brands
+ */
+FontAwesome.library.add(
+  (faListOldStyle = {
+    prefix: "fa-custom",
+    iconName: "pypi",
+    icon: [
+      17.313, // viewBox width
+      19.807, // viewBox height
+      [], // ligature
+      "e001", // unicode codepoint - private use area
+      "m10.383 0.2-3.239 1.1769 3.1883 1.1614 3.239-1.1798zm-3.4152 1.2411-3.2362 1.1769 3.1855 1.1614 3.2369-1.1769zm6.7177 0.00281-3.2947 1.2009v3.8254l3.2947-1.1988zm-3.4145 1.2439-3.2926 1.1981v3.8254l0.17548-0.064132 3.1171-1.1347zm-6.6564 0.018325v3.8247l3.244 1.1805v-3.8254zm10.191 0.20931v2.3137l3.1777-1.1558zm3.2947 1.2425-3.2947 1.1988v3.8254l3.2947-1.1988zm-8.7058 0.45739c0.00929-1.931e-4 0.018327-2.977e-4 0.027485 0 0.25633 0.00851 0.4263 0.20713 0.42638 0.49826 1.953e-4 0.38532-0.29327 0.80469-0.65542 0.93662-0.36226 0.13215-0.65608-0.073306-0.65613-0.4588-6.28e-5 -0.38556 0.2938-0.80504 0.65613-0.93662 0.068422-0.024919 0.13655-0.038114 0.20156-0.039466zm5.2913 0.78369-3.2947 1.1988v3.8247l3.2947-1.1981zm-10.132 1.239-3.2362 1.1769 3.1883 1.1614 3.2362-1.1769zm6.7177 0.00213-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4124 1.2439-3.2947 1.1988v3.8254l3.2947-1.1988zm-6.6585 0.016195v3.8275l3.244 1.1805v-3.8254zm16.9 0.21143-3.2947 1.1988v3.8247l3.2947-1.1981zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4124 1.2432-3.2947 1.1988v3.8254l3.2947-1.1988zm-6.6585 0.019027v3.8247l3.244 1.1805v-3.8254zm13.485 1.4497-3.2947 1.1988v3.8247l3.2947-1.1981zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm2.4018 0.38127c0.0093-1.83e-4 0.01833-3.16e-4 0.02749 0 0.25633 0.0085 0.4263 0.20713 0.42638 0.49826 1.97e-4 0.38532-0.29327 0.80469-0.65542 0.93662-0.36188 0.1316-0.65525-0.07375-0.65542-0.4588-1.95e-4 -0.38532 0.29328-0.80469 0.65542-0.93662 0.06842-0.02494 0.13655-0.03819 0.20156-0.03947zm-5.8142 0.86403-3.244 1.1805v1.4201l3.244 1.1805z", // svg path (https://simpleicons.org/icons/pypi.svg)
+    ],
+  }),
+);
+
+/*******************************************************************************
+ * Set a custom icon for Falcon as it's not available in the fa built-in brands.
+ * NOTE(vytas): But one day we'll be there ("big as Django").
+ */
+FontAwesome.library.add(
+  (faListOldStyle = {
+    prefix: "fa-custom",
+    iconName: "falcon",
+    icon: [
+      16.0, // viewBox width
+      16.0, // viewBox height
+      [], // ligature
+      "e002", // unicode codepoint - private use area
+      "M 2.54188 0.40205 C 1.38028 1.60365 0.794478 2.81526 0.794478 4.02187 C 0.794478 4.29723 0.814578 4.53255 0.844578 4.54757 C 0.874678 4.56258 0.894678 4.62768 0.894678 4.68775 C 0.894678 4.82794 1.01478 5.1734 1.17508 5.49883 C 1.38528 5.93441 1.69068 6.23982 2.52188 6.84062 C 3.17268 7.31124 3.53318 7.84195 3.65338 8.51284 L 3.71838 8.87332 L 3.89868 8.79822 C 4.35428 8.60296 4.70468 8.53287 5.75108 8.42272 C 6.42198 8.35263 6.68238 8.2525 6.99278 7.94709 C 7.43336 7.5065 7.22308 7.22112 5.84628 6.40504 C 4.36428 5.52386 3.83358 5.11332 3.25778 4.39737 C 2.79718 3.8216 2.56188 3.34597 2.44668 2.75518 C 2.33658 2.20445 2.48678 1.33329 2.82218 0.55225 C 2.91738 0.321944 2.99748 0.121677 2.99748 0.101651 C 2.99748 -0.00348278 2.83228 0.106713 2.54188 0.40205 Z M 15.8896 0.80759 C 15.8646 0.827621 15.7995 0.927752 15.7544 1.02788 C 15.464 1.64871 14.5028 2.23449 12.9857 2.70511 C 12.8506 2.75018 12.6102 2.82026 12.46 2.87033 C 12.3098 2.9154 12.0144 3.01052 11.8092 3.0706 C 9.26079 3.84663 8.8152 4.24717 7.90398 6.55023 C 7.49344 7.5816 7.27815 7.92706 6.85258 8.20744 C 6.49708 8.44776 6.20668 8.52786 5.40068 8.628 C 4.11898 8.7832 3.62328 8.9334 3.22278 9.28887 C 2.97738 9.49915 2.89728 9.71945 2.89728 10.16 C 2.89728 10.3603 2.87728 10.5355 2.85728 10.5506 C 2.83218 10.5656 2.78718 10.7008 2.75208 10.856 C 2.70208 11.0662 2.70208 11.1714 2.74698 11.3066 C 2.81708 11.5118 3.01238 11.6821 3.18758 11.6821 C 3.31278 11.6821 3.31778 11.647 3.22268 11.4217 C 3.20258 11.3666 3.23268 11.2965 3.31778 11.2064 C 3.42798 11.0963 3.48298 11.0813 3.82348 11.0813 C 4.21398 11.0813 4.34418 11.0412 4.52938 10.871 C 4.63458 10.7759 4.63958 10.7759 4.77978 10.9311 C 5.07518 11.2565 5.84618 11.7171 6.80748 12.1477 C 7.26304 12.353 7.54842 12.5983 7.76371 12.9788 C 7.9089 13.2291 7.92893 13.2992 7.92893 13.6597 C 7.92893 14.2455 7.71865 14.646 7.3081 14.8363 C 7.23801 14.8713 7.13787 14.9164 7.09281 14.9414 C 7.04274 14.9664 6.85748 14.9864 6.67728 14.9864 L 6.35188 14.9864 L 6.24668 15.2218 C 6.18658 15.3569 6.11148 15.5222 6.07148 15.5923 C 6.01638 15.7024 6.01638 15.7374 6.07148 15.8176 C 6.13658 15.9027 6.15658 15.8927 6.39688 15.6473 C 6.64718 15.392 6.80238 15.3169 6.80238 15.4571 C 6.80238 15.4971 6.71228 15.5973 6.60718 15.6824 C 6.36188 15.8776 6.32678 16.0178 6.44198 16.3483 C 6.53708 16.6186 6.66228 16.6987 6.69228 16.5135 C 6.70228 16.4584 6.77738 16.3483 6.85748 16.2682 C 6.93758 16.193 7.00268 16.0979 7.00268 16.0629 C 7.00268 16.0278 7.03775 15.9728 7.0778 15.9377 C 7.18794 15.8476 7.233 16.0078 7.16292 16.2581 C 7.11786 16.4284 7.12286 16.4784 7.18794 16.5485 C 7.23801 16.6036 7.25303 16.6787 7.23301 16.7638 C 7.21303 16.8539 7.22304 16.889 7.26811 16.889 C 7.3382 16.889 7.50342 16.7338 7.50342 16.6687 C 7.50342 16.6487 7.54848 16.5636 7.60856 16.4834 C 7.7137 16.3332 7.7187 16.3032 7.62358 15.7725 C 7.60856 15.6724 7.61357 15.5873 7.63359 15.5873 C 7.73873 15.5873 8.09921 15.8176 8.11924 15.8977 C 8.15428 16.0278 8.30448 15.9527 8.30448 15.8025 C 8.30448 15.6123 8.1693 15.402 8.00408 15.3269 C 7.83886 15.2568 7.80882 15.1467 7.944 15.1066 C 8.25946 15.0065 8.55485 14.8262 8.8202 14.5609 C 9.14063 14.2505 9.1957 14.1854 9.40098 13.885 C 9.68636 13.4694 9.95171 13.2842 10.2571 13.2842 C 10.6777 13.2842 11.9994 13.875 13.0208 14.5208 C 13.4964 14.8212 14.4277 15.4721 14.5829 15.6123 C 14.6129 15.6423 14.7831 15.7675 14.9634 15.8977 C 15.1436 16.0228 15.454 16.2632 15.6543 16.4334 C 15.8546 16.5986 16.0348 16.7388 16.0548 16.7388 C 16.22 16.7388 16.2751 16.3082 16.1299 16.178 C 16.0799 16.138 15.9046 16.0278 15.7394 15.9377 C 15.3739 15.7324 14.668 15.2568 14.3626 15.0015 C 13.6717 14.4357 12.7905 13.3293 12.2047 12.2929 C 12.1496 12.2078 12.0645 12.0526 12.0094 11.9524 C 11.5889 11.2164 10.7878 10.2802 10.5725 10.2802 C 10.4874 10.2802 10.4874 10.2752 10.6226 9.62933 C 10.6626 9.42405 10.7227 9.07359 10.7528 8.8533 C 10.8429 8.24248 10.9731 7.54656 11.0682 7.2011 C 11.3385 6.18474 11.9143 5.47881 12.9607 4.88802 C 14.4277 4.05191 15.0134 3.53622 15.434 2.70011 C 15.6593 2.25451 15.7044 2.14437 15.8395 1.6437 C 15.9096 1.38836 15.9947 0.767537 15.9597 0.767537 C 15.9497 0.767537 15.9196 0.787568 15.8896 0.807587 Z M 7.22808 12.6483 C 7.21807 12.6834 7.20305 12.8086 7.18803 12.9387 C 7.158 13.2892 6.92268 13.7548 6.65228 14.0052 C 6.32688 14.3106 6.12658 14.4057 5.95638 14.3406 C 5.88628 14.3156 5.68608 14.2755 5.51578 14.2605 C 5.26548 14.2305 5.17538 14.2455 5.05518 14.3156 C 4.92498 14.3907 4.89998 14.4407 4.89998 14.5959 C 4.89998 14.8162 4.89488 14.8112 5.08018 14.7111 C 5.16038 14.671 5.25548 14.636 5.29048 14.636 C 5.39568 14.636 5.35558 14.7361 5.22538 14.7962 C 5.08028 14.8613 5.04018 14.9864 5.03018 15.377 C 5.02508 15.6724 5.11528 15.7875 5.19538 15.5772 C 5.22038 15.5122 5.31558 15.422 5.40568 15.372 C 5.50078 15.3219 5.63098 15.1917 5.70108 15.0866 C 5.83118 14.8663 5.90128 14.8312 5.90128 14.9814 C 5.90128 15.3019 6.06658 15.3169 6.20668 15.0065 C 6.29188 14.8363 6.33688 14.7912 6.39698 14.8162 C 6.56218 14.8863 6.75748 14.8963 6.82758 14.8363 C 6.89768 14.7762 6.89268 14.7562 6.79748 14.656 C 6.73748 14.5909 6.69238 14.5309 6.70738 14.5208 C 6.81758 14.4407 7.05787 14.3857 7.3032 14.3857 C 7.74378 14.3857 7.80386 14.3106 7.80386 13.7448 C 7.80386 13.169 7.65366 12.7585 7.40833 12.6534 C 7.28317 12.6033 7.24311 12.5983 7.22809 12.6483 Z M 11.2584 13.7248 C 11.2584 13.7498 11.4437 13.865 11.674 13.9801 C 12.7805 14.5659 13.4614 15.2017 13.7167 15.9077 C 13.8319 16.2131 13.8369 16.3232 13.7367 16.5535 C 13.6116 16.8589 13.5915 16.8539 14.4026 16.8289 C 15.1336 16.8039 15.5642 16.7238 15.5642 16.6086 C 15.5642 16.5535 14.9634 15.9878 14.7431 15.8426 C 14.683 15.8025 14.6279 15.7525 14.6129 15.7375 C 14.5979 15.7224 14.4978 15.6423 14.3876 15.5572 C 14.2775 15.4771 14.1573 15.387 14.1273 15.362 C 14.0922 15.3319 14.0121 15.2718 13.952 15.2268 C 13.8869 15.1767 13.7468 15.0666 13.6366 14.9815 C 13.5265 14.8963 13.3562 14.7812 13.2611 14.7311 C 13.0308 14.606 12.8906 14.5158 12.8606 14.4808 C 12.8255 14.4407 12.1346 14.0652 11.8092 13.91 C 11.659 13.8399 11.4938 13.7598 11.4487 13.7348 C 11.3385 13.6747 11.2584 13.6697 11.2584 13.7248 Z",
+    ],
+  }),
+);
diff --git a/docs/_static/custom.css b/docs/_static/custom.css
index 8948ecc9..781a7ff6 100644
--- a/docs/_static/custom.css
+++ b/docs/_static/custom.css
@@ -1,370 +1,26 @@
-body {
-    font-family: Oxygen, 'goudy old style', 'minion pro', 'bell mt', Georgia, 'Hiragino Mincho Pro', serif;
-    font-size: 17.5px;
-}
-
-
-.field-name {
-    /* Fix for https://github.com/bitprophet/alabaster/issues/95 */
-    -moz-hyphens: manual;
-    -ms-hyphens: manual;
-    -webkit-hyphens: manual;
-    hyphens: manual;
-
-    width: 110px;  /* Prevent "Return type:" from wrapping. */
-}
-
-a {
-    text-decoration: none;
-}
-
-h1 a:hover, h2 a:hover, h3 a:hover {
-    text-decoration: none;
-    border: none;
-}
-
-div.document {
-    /* max body width + 60px padding + 220px sidebar */
-    max-width: calc(760px + 60px + 220px);
-}
-
-div.footer {
-    text-align: center;
-}
-
-div.footer a:hover {
-    border-bottom: none;
-}
-
-dd ul, dd table {
-    margin-bottom: 1.4em;
-}
-
-table.field-list th, table.field-list td {
-    padding-top: 1em;
-}
-
-table.field-list tbody tr:first-child th, table.field-list tbody tr:first-child td {
-    padding-top: 0;
-}
-
-code.docutils.literal {
-    background-color: rgba(0, 0, 0, 0.06);
-    padding: 2px 5px 1px 5px;
-    font-size: 0.88em;
-}
-
-code.xref.docutils.literal {
-    background-color: transparent;
-    padding: 0;
-    font-size: 0.9em;
-}
-
-div.viewcode-block:target {
-    background: inherit;
-    background-color: #dadada;
-    border-radius: 5px;
-    padding: 5px;
-}
-
-a:hover, div.sphinxsidebar a:hover, a.reference:hover, a.reference.internal:hover code {
-    color: #f0ad4e;
-    border-bottom: 1px solid #f0ad4e;
-}
-
-a, div.sphinxsidebar a, a.reference, a code.literal {
-    color: #c77c11;
-    border: none;
-}
-
-.highlight pre span {
-    line-height: 1.5em;
-}
-
-.field-body cite {
-    font-style: normal;
-    font-weight: bold;
-}
-
-/* Hide theme's default logo section */
-.logo a {
-    display: none;
-}
-
-#logo {
-    position: relative;
-    left: -13px;
-}
-
-#logo a,
-#logo a:hover {
-    border-bottom: none;
-}
-
-#logo img {
-    margin: 0;
-    padding: 0;
-}
-
-#gh-buttons {
-    margin-top: 2em;
-}
-
-#dev-warning {
-    background-color: #fdfbe8;
-    border: 1px solid #ccc;
-    padding: 10px;
-    margin-bottom: 1em;
- }
-
-div.warning {
-    background-color: #fdfbe8;
-    border: 1px solid #ccc;
-}
-
-div.body h1,
-div.body h2,
-div.body h3,
-div.body h4,
-div.body h5,
-div.body h6,
-div.sphinxsidebar h3 {
-    font-family: Oxygen, 'goudy old style', serif;
-    font-weight: bold;
-    color: #444;
-}
-
-div.sphinxsidebar h3 {
-    margin: 1.5em 0 0 0;
-}
-
-div.sphinxsidebar h4 {
-    font-family: Oxygen, Garamond, Georgia, serif;
-}
-
-div.sphinxsidebar ul {
-    margin-top: 5px;
-}
-
-div.sphinxsidebarwrapper {
-    padding-top: 0;
-}
-
-div.admonition p.admonition-title {
-   display: block;
-   line-height: 1.4em;
-   font-family: Oxygen, Garamond, Georgia, serif;
-}
-
-div.admonition .last.highlight-default {
-    display: inline-block;
-    font-size: smaller;
-}
-
-pre {
-    /*background-color: #212529;*/
-    padding: 1.25em 30px;
-}
-
-.sphinx-tabs div.node,
-.sphinx-tabs div.admonition,
-.sphinx-tabs div.highlight
-{
-    margin-left: -8px;
-    margin-right: -8px;
-}
-
-.highlight pre {
-    font-size: 14px;
-}
-
-
-/* NOTE(kgriffs): Make sure that characters in plain text blocks line up correctly. */
-.highlight-none .highlight pre {
-    line-height: 1em;
-    font-family: monospace;
-}
-
-/* Fix drifting to the left in some parameters lists. */
-.field-body li .highlight pre {
-    float: right;
-}
-
-div input[type="text"] {
-    padding: 5px;
-    margin: 0 0 0.5em 0;
-    font-family: Oxygen, Garamond, Georgia, serif;
-}
-
-div input[type="submit"] {
-    font-size: 14px;
-    width: 72px;
-    height: 27px;
-    font-weight: 600;
-    font-family: Oxygen, Garamond, Georgia, serif;
-
-    border: 1px solid #d5d5d5;
-    border-radius: 3px;
-    padding: 0 10px;
-
-    color: #333;
-    background-color: #eee;
-    background-image: linear-gradient(to bottom,#fcfcfc,#eee);
-}
-
-div input[type="submit"]:hover {
-    background-color: #ddd;
-    background-image: linear-gradient(to bottom,#eee,#ddd);
-    border-color: #ccc;
-}
-
-div input[type="submit"]:active, #searchbox input[type="submit"]:active {
-    background-color: #dcdcdc;
-    background-image: none;
-    border-color: #b5b5b5;
-    box-shadow: inset 0 2px 4px rgba(0,0,0,.15);
-}
-
-div input[type="submit"]:focus {
-    outline: none;
-}
-
-input[type=text]:focus {
-    outline: 1px solid #999;
-}
-
-#sidebarSupportFalcon a:hover, #sidebarSupportFalcon a:focus {
-    text-decoration: none;
-    border: none;
-    outline: none;
-}
-
-dl.field-list > dt {
-    word-break: normal;
-    padding-left: 0;
-    hyphens: none;
-    margin-top: 0.2em;
-}
-
-dl.field-list > dd {
-    margin-top: 0.2em;
-}
-
-dl.field-list ul {
-    list-style: none;
-}
-
-dl.field-list ul li {
-    list-style: none;
-    padding-bottom: 0.5em;
-}
-
-dl.field-list.simple dd ul {
-    padding-left: 0;
-}
-
-dl.field-list.simple dd ul li p:not(:first-child) {
-    margin-top: 0.4em;
-}
-
-dl.attribute > dd > p {
-    margin: 0 0 1em 0;
-}
-
-/* NOTE(kgriffs): Fix spacing issue with embedded Note blocks, and make
-     things generally more readable by spacing the paragraphs. */
-.field-list p {
-    margin-bottom: 1em;
-}
-
-dl.function, dl.method, dl.attribute, dl.class {
-    padding-top: 1em;
-}
-
-div.body div.section > ul.simple > li > dl.simple > dd > ul {
-    margin: 0;
-}
-
-div.body div.toctree-wrapper.compound > ul > li.toctree-l1 > ul {
-    margin-top: 0;
-}
-
-div.contents.local.topic {
-    background: none;
-    border: none;
-}
-
-div.contents.local.topic > ul > li > p {
-    margin-bottom: 0;
-}
-
-div.contents.local.topic > ul > li > ul {
-    margin-top: 0;
-}
-
-[role="tablist"] {
-    border-color: #eee;
-}
-
-.sphinx-tabs-panel {
-    border-color: #eee;
-    padding: 4px 1rem;
-}
-
-.sphinx-tabs-tab {
-    top: 2px;
-    border-color: #eee;
-    color: #c77c11;
-}
-
-.sphinx-tabs-tab[aria-selected="true"] {
-    border-color: #eee;
-}
-
-.sphinx-tabs-tab[aria-selected="false"]:hover {
-    color: #f0ad4e;
-    text-decoration: underline;
-}
-
-.sphinx-tabs-tab:focus {
-    z-index: 0;
-}
-
-.sphinx-tabs-panel .highlight-python {
-    margin: 4px 0;
-}
-
-div.note, pre {
-    background-color: rgb(245,245,245);
-}
-
-div.note div.highlight > pre, div.admonition div.highlight > pre {
-    border: none;
-}
-
-div.note, div.admonition {
-    /* Match tab radius */
-    border-radius: .285714rem;
-    border-color: #eee;
-    padding-bottom: 1em;
-}
-
-div.ui.bottom.attached.sphinx-tab.tab {
-    /* Match tab radius */
-    border-bottom-left-radius: .285714rem;
-    border-bottom-right-radius: .285714rem;
-}
-
-div.highlight > pre {
-    border: 1px solid #eee;
-    border-radius: .285714rem;
-}
-
-.sphinx-tab pre {
-    border: none !important;
-}
-
-/* TODO: remove once alabaster is updated */
-span.descname, span.descclassname {
-    font-size: 0.95em;
+/* Customize the PyData theme's font colors. */
+
+/* Some ideas partially inspired by Bokeh docs. */
+html[data-theme=light] {
+  --pst-color-borders: rgb(206, 212, 218);
+  --pst-color-primary: var(--pst-color-text-base);
+  /* Darken Falconry orange to meet WCAG 2.1 AA contrast */
+  --pst-color-secondary: rgb(169, 103, 9);
+  --pst-color-link: var(--pst-color-secondary);
+  --pst-color-success: rgb(165, 205, 57);
+  --pst-color-admonition-tip: var(--pst-color-success);
+  --pst-color-inline-code: var(--pst-color-text-base);
+  --pst-color-inline-code-links: var(--pst-color-secondary);
+  --pst-color-surface: rgb(255, 250, 234);
+}
+
+html[data-theme=dark] {
+  --pst-color-primary: var(--pst-color-text-base);
+  --pst-color-secondary: rgb(240, 173, 78);
+  --pst-color-link: var(--pst-color-secondary);
+  --pst-color-inline-code: var(--pst-color-text-base);
+  --pst-color-inline-code-links: var(--pst-color-secondary);
+  --pst-color-background: rgb(8, 6, 2);
+  --pst-color-on-background: rgb(39, 37, 35);
+  --pst-color-surface: rgb(31, 31, 31);
 }
diff --git a/docs/api/app.rst b/docs/api/app.rst
index b865a2d6..9e56a451 100644
--- a/docs/api/app.rst
+++ b/docs/api/app.rst
@@ -3,10 +3,6 @@
 The App Class
 =============
 
-* `WSGI App`_
-* `ASGI App`_
-* `Options`_
-
 Falcon supports both the WSGI (:class:`falcon.App`) and
 ASGI (:class:`falcon.asgi.App`) protocols. This is done
 by instantiating the respective ``App`` class to create a
diff --git a/docs/api/cookies.rst b/docs/api/cookies.rst
index 719e025e..5089957c 100644
--- a/docs/api/cookies.rst
+++ b/docs/api/cookies.rst
@@ -3,7 +3,7 @@
 Cookies
 -------
 
-.. contents:: :local:
+This page describes the API provided by Falcon to manipulate cookies.
 
 .. _getting-cookies:
 
@@ -24,9 +24,9 @@ need a collection of all the cookies in the request.
 
 Here's an example showing how to get cookies from a request:
 
-.. tabs::
+.. tab-set::
 
-    .. tab:: WSGI
+    .. tab-item:: WSGI
 
         .. code:: python
 
@@ -43,7 +43,7 @@ Here's an example showing how to get cookies from a request:
                         #   will need to choose how to handle the additional values.
                         v = my_cookie_values[0]
 
-    .. tab:: ASGI
+    .. tab-item:: ASGI
 
         .. code:: python
 
diff --git a/docs/api/cors.rst b/docs/api/cors.rst
index d19bf7a7..89927807 100644
--- a/docs/api/cors.rst
+++ b/docs/api/cors.rst
@@ -29,9 +29,9 @@ can be exposed.
 Usage
 -----
 
-.. tabs::
+.. tab-set::
 
-    .. tab:: WSGI
+    .. tab-item:: WSGI
 
         .. code:: python
 
@@ -45,7 +45,7 @@ Usage
             app = falcon.App(middleware=falcon.CORSMiddleware(
                 allow_origins='example.com', allow_credentials='*'))
 
-    .. tab:: ASGI
+    .. tab-item:: ASGI
 
         .. code:: python
 
diff --git a/docs/api/errors.rst b/docs/api/errors.rst
index 462e188a..528d435e 100644
--- a/docs/api/errors.rst
+++ b/docs/api/errors.rst
@@ -3,8 +3,6 @@
 Error Handling
 ==============
 
-.. contents:: :local:
-
 When it comes to error handling, you can always directly set the error
 status, appropriate response headers, and error body using the ``resp``
 object. However, Falcon tries to make things a little easier by
@@ -48,9 +46,9 @@ To customize what data is passed to the serializer, subclass
 
 All classes are available directly in the ``falcon`` package namespace:
 
-.. tabs::
+.. tab-set::
 
-    .. tab:: WSGI
+    .. tab-item:: WSGI
 
         .. code:: python
 
@@ -68,7 +66,7 @@ All classes are available directly in the ``falcon`` package namespace:
 
                     # -- snip --
 
-    .. tab:: ASGI
+    .. tab-item:: ASGI
 
         .. code:: python
 
diff --git a/docs/api/hooks.rst b/docs/api/hooks.rst
index a3adf433..91aacd3a 100644
--- a/docs/api/hooks.rst
+++ b/docs/api/hooks.rst
@@ -3,9 +3,6 @@
 Hooks
 =====
 
-* `Before Hooks`_
-* `After Hooks`_
-
 Falcon supports *before* and *after* hooks. You install a hook simply by
 applying one of the decorators below, either to an individual responder or
 to an entire resource.
diff --git a/docs/api/inspect.rst b/docs/api/inspect.rst
index 48cbfaec..7e3d4628 100644
--- a/docs/api/inspect.rst
+++ b/docs/api/inspect.rst
@@ -3,12 +3,6 @@
 Inspect Module
 ==============
 
-* `Using Inspect Functions`_
-* `Inspect Functions Reference`_
-* `Router Inspection`_
-* `Information Classes`_
-* `Visitor Classes`_
-
 This module can be used to inspect a Falcon application to obtain information
 about its registered routes, middleware objects, static routes, sinks and
 error handlers. The entire application can be inspected at once using the
diff --git a/docs/api/media.rst b/docs/api/media.rst
index dbff0538..ac8105d9 100644
--- a/docs/api/media.rst
+++ b/docs/api/media.rst
@@ -3,8 +3,6 @@
 Media
 =====
 
-.. contents:: :local:
-
 Falcon allows for easy and customizable internet media type handling. By
 default Falcon only enables handlers for JSON and HTML (URL-encoded and
 multipart) forms. However, additional handlers can be configured through the
@@ -26,9 +24,9 @@ Zero configuration is needed if you're creating a JSON API. Simply use
 :attr:`~falcon.asgi.Response.media` (ASGI) to let Falcon
 do the heavy lifting for you.
 
-.. tabs::
+.. tab-set::
 
-    .. tab:: WSGI
+    .. tab-item:: WSGI
 
         .. code:: python
 
@@ -52,7 +50,7 @@ do the heavy lifting for you.
                     resp.media = {'message': message}
                     resp.status = falcon.HTTP_200
 
-    .. tab:: ASGI
+    .. tab-item:: ASGI
 
         .. code:: python
 
@@ -109,9 +107,9 @@ response.
 If you do need full negotiation, it is very easy to bridge the gap using
 middleware. Here is an example of how this can be done:
 
-.. tabs::
+.. tab-set::
 
-    .. tab:: WSGI
+    .. tab-item:: WSGI
 
         .. code:: python
 
@@ -121,7 +119,7 @@ middleware. Here is an example of how this can be done:
                 def process_request(self, req: Request, resp: Response) -> None:
                     resp.content_type = req.accept
 
-    .. tab:: ASGI
+    .. tab-item:: ASGI
 
         .. code:: python
 
diff --git a/docs/api/middleware.rst b/docs/api/middleware.rst
index 3cfbb18c..251e8e8c 100644
--- a/docs/api/middleware.rst
+++ b/docs/api/middleware.rst
@@ -3,8 +3,6 @@
 Middleware
 ==========
 
-.. contents:: :local:
-
 Middleware components provide a way to execute logic before the
 framework routes each request, after each request is routed but before
 the target responder is called, or just before the response is returned
@@ -18,9 +16,9 @@ when instantiating Falcon's :ref:`App class <app>`. A middleware component
 is simply a class that implements one or more of the event handler methods
 defined below.
 
-.. tabs::
+.. tab-set::
 
-    .. tab:: WSGI
+    .. tab-item:: WSGI
 
         Falcon's middleware interface is defined as follows:
 
@@ -96,7 +94,7 @@ defined below.
 
             app = App(middleware=[ExampleMiddleware()])
 
-    .. tab:: ASGI
+    .. tab-item:: ASGI
 
         The ASGI middleware interface is similar to WSGI, but also supports the
         standard ASGI lifespan events. However, because lifespan events are an
diff --git a/docs/api/multipart.rst b/docs/api/multipart.rst
index cf262041..a4bb664c 100644
--- a/docs/api/multipart.rst
+++ b/docs/api/multipart.rst
@@ -3,17 +3,16 @@
 Multipart Forms
 ===============
 
-.. contents:: :local:
-
 Falcon features easy and efficient access to submitted multipart forms by using
 :class:`~falcon.media.MultipartFormHandler` to handle the
 ``multipart/form-data`` :ref:`media <media>` type. This handler is enabled by
 default, allowing you to use ``req.get_media()`` to iterate over the
 :class:`body parts <falcon.media.multipart.BodyPart>` in a form:
 
-.. tabs::
+.. tab-set::
 
-    .. group-tab:: WSGI
+    .. tab-item:: WSGI
+        :sync: wsgi
 
         .. code:: python
 
@@ -38,7 +37,8 @@ default, allowing you to use ``req.get_media()`` to iterate over the
                     # Do something else
                     form_data[part.name] = part.text
 
-    .. group-tab:: ASGI
+    .. tab-item:: ASGI
+        :sync: asgi
 
         .. code:: python
 
@@ -72,9 +72,10 @@ default, allowing you to use ``req.get_media()`` to iterate over the
 Multipart Form and Body Part Types
 ----------------------------------
 
-.. tabs::
+.. tab-set::
 
-    .. group-tab:: WSGI
+    .. tab-item:: WSGI
+        :sync: wsgi
 
         .. autoclass:: falcon.media.multipart.MultipartForm
             :members:
@@ -82,7 +83,8 @@ Multipart Form and Body Part Types
         .. autoclass:: falcon.media.multipart.BodyPart
             :members:
 
-    .. group-tab:: ASGI
+    .. tab-item:: ASGI
+        :sync: asgi
 
         .. autoclass:: falcon.asgi.multipart.MultipartForm
             :members:
@@ -126,9 +128,10 @@ way is to directly modify the properties of this attribute on the media handler
 In order to use your customized handler in an app, simply replace the default
 handler for ``multipart/form-data`` with the new one:
 
-.. tabs::
+.. tab-set::
 
-    .. group-tab:: WSGI
+    .. tab-item:: WSGI
+        :sync: wsgi
 
         .. code:: python
 
@@ -137,7 +140,8 @@ handler for ``multipart/form-data`` with the new one:
             # handler is instantiated and configured as per the above snippet
             app.req_options.media_handlers[falcon.MEDIA_MULTIPART] = handler
 
-    .. group-tab:: ASGI
+    .. tab-item:: ASGI
+        :sync: asgi
 
         .. code:: python
 
diff --git a/docs/api/request_and_response_asgi.rst b/docs/api/request_and_response_asgi.rst
index 8d7d154c..579d3d66 100644
--- a/docs/api/request_and_response_asgi.rst
+++ b/docs/api/request_and_response_asgi.rst
@@ -3,9 +3,6 @@
 ASGI Request & Response
 =======================
 
-* `Request`_
-* `Response`_
-
 Instances of the :class:`falcon.asgi.Request` and
 :class:`falcon.asgi.Response` classes are passed into responders as
 the second and third arguments, respectively:
diff --git a/docs/api/request_and_response_wsgi.rst b/docs/api/request_and_response_wsgi.rst
index 540f26f4..3b715b64 100644
--- a/docs/api/request_and_response_wsgi.rst
+++ b/docs/api/request_and_response_wsgi.rst
@@ -3,9 +3,6 @@
 WSGI Request & Response
 =======================
 
-* `Request`_
-* `Response`_
-
 Instances of the :class:`falcon.Request` and
 :class:`falcon.Response` classes are passed into WSGI app responders as the
 second and third arguments, respectively:
diff --git a/docs/api/routing.rst b/docs/api/routing.rst
index 347b3516..c3a3fc41 100644
--- a/docs/api/routing.rst
+++ b/docs/api/routing.rst
@@ -3,8 +3,6 @@
 Routing
 =======
 
-.. contents:: :local:
-
 Falcon uses resource-based routing to encourage a RESTful architectural style.
 Each resource is represented by a class that is responsible for handling all of
 the HTTP methods that the resource supports.
@@ -29,9 +27,9 @@ associated resource for processing.
 
 Here's a quick example to show how all the pieces fit together:
 
-.. tabs::
+.. tab-set::
 
-    .. tab:: WSGI
+    .. tab-item:: WSGI
 
         .. code:: python
 
@@ -66,7 +64,7 @@ Here's a quick example to show how all the pieces fit together:
             images = ImagesResource()
             app.add_route('/images', images)
 
-    .. tab:: ASGI
+    .. tab-item:: ASGI
 
         .. code:: python
 
@@ -205,9 +203,9 @@ A PUT request to ``'/user/kgriffs'`` would cause the framework to invoke
 the ``on_put()`` responder method on the route's resource class, passing
 ``'kgriffs'`` via an additional `name` argument defined by the responder:
 
-.. tabs::
+.. tab-set::
 
-    .. tab:: WSGI
+    .. tab-item:: WSGI
 
         .. code:: python
 
@@ -216,7 +214,7 @@ the ``on_put()`` responder method on the route's resource class, passing
             def on_put(self, req, resp, name):
                 pass
 
-    .. tab:: ASGI
+    .. tab-item:: ASGI
 
         .. code:: python
 
@@ -480,8 +478,6 @@ be used by custom routing engines.
 
 .. autofunction:: falcon.routing.set_default_responders
 
-.. autofunction:: falcon.routing.compile_uri_template
-
 .. autofunction:: falcon.app_helpers.prepare_middleware
 
 .. autofunction:: falcon.app_helpers.prepare_middleware_ws
@@ -511,9 +507,9 @@ support custom HTTP methods, use one of the following methods:
 Once you have used the appropriate method, your custom methods should be active.
 You then can define request methods like any other HTTP method:
 
-.. tabs::
+.. tab-set::
 
-    .. tab:: WSGI
+    .. tab-item:: WSGI
 
         .. code:: python
 
@@ -521,7 +517,7 @@ You then can define request methods like any other HTTP method:
             def on_foo(self, req, resp):
                 pass
 
-    .. tab:: ASGI
+    .. tab-item:: ASGI
 
         .. code:: python
 
diff --git a/docs/api/status.rst b/docs/api/status.rst
index c48d7c91..13f6a1fd 100644
--- a/docs/api/status.rst
+++ b/docs/api/status.rst
@@ -3,8 +3,6 @@
 Status Codes
 ============
 
-.. contents:: :local:
-
 Falcon provides a list of constants for common
 `HTTP response status codes <http://httpstatus.es>`_.
 
@@ -66,10 +64,12 @@ HTTPStatus
     HTTP_CONTINUE = HTTP_100
     HTTP_SWITCHING_PROTOCOLS = HTTP_101
     HTTP_PROCESSING = HTTP_102
+    HTTP_EARLY_HINTS = HTTP_103
 
     HTTP_100 = '100 Continue'
     HTTP_101 = '101 Switching Protocols'
     HTTP_102 = '102 Processing'
+    HTTP_103 = '103 Early Hints'
 
 2xx Success
 -----------
@@ -145,6 +145,7 @@ HTTPStatus
     HTTP_REQUESTED_RANGE_NOT_SATISFIABLE = HTTP_416
     HTTP_EXPECTATION_FAILED = HTTP_417
     HTTP_IM_A_TEAPOT = HTTP_418
+    HTTP_MISDIRECTED_REQUEST = HTTP_421
     HTTP_UNPROCESSABLE_ENTITY = HTTP_422
     HTTP_LOCKED = HTTP_423
     HTTP_FAILED_DEPENDENCY = HTTP_424
@@ -173,6 +174,7 @@ HTTPStatus
     HTTP_416 = '416 Range Not Satisfiable'
     HTTP_417 = '417 Expectation Failed'
     HTTP_418 = "418 I'm a teapot"
+    HTTP_421 = '421 Misdirected Request'
     HTTP_422 = "422 Unprocessable Entity"
     HTTP_423 = '423 Locked'
     HTTP_424 = '424 Failed Dependency'
@@ -193,8 +195,10 @@ HTTPStatus
     HTTP_SERVICE_UNAVAILABLE = HTTP_503
     HTTP_GATEWAY_TIMEOUT = HTTP_504
     HTTP_HTTP_VERSION_NOT_SUPPORTED = HTTP_505
+    HTTP_VARIANT_ALSO_NEGOTIATES = HTTP_506
     HTTP_INSUFFICIENT_STORAGE = HTTP_507
     HTTP_LOOP_DETECTED = HTTP_508
+    HTTP_NOT_EXTENDED = HTTP_510
     HTTP_NETWORK_AUTHENTICATION_REQUIRED = HTTP_511
 
     HTTP_500 = '500 Internal Server Error'
@@ -203,6 +207,8 @@ HTTPStatus
     HTTP_503 = '503 Service Unavailable'
     HTTP_504 = '504 Gateway Timeout'
     HTTP_505 = '505 HTTP Version Not Supported'
+    HTTP_506 = '506 Variant Also Negotiates'
     HTTP_507 = '507 Insufficient Storage'
     HTTP_508 = '508 Loop Detected'
+    HTTP_510 = '510 Not Extended'
     HTTP_511 = '511 Network Authentication Required'
diff --git a/docs/api/testing.rst b/docs/api/testing.rst
index c68267d3..67357cb5 100644
--- a/docs/api/testing.rst
+++ b/docs/api/testing.rst
@@ -3,8 +3,6 @@
 Testing Helpers
 ===============
 
-.. contents:: :local:
-
 .. automodule:: falcon.testing
     :noindex:
 
diff --git a/docs/api/util.rst b/docs/api/util.rst
index d5dce81c..094bf443 100644
--- a/docs/api/util.rst
+++ b/docs/api/util.rst
@@ -3,7 +3,7 @@
 Utilities
 =========
 
-.. contents:: :local:
+This page describes miscellaneous utilities provided by Falcon.
 
 URI
 ---
@@ -32,7 +32,6 @@ HTTP Status
 
 .. autofunction:: falcon.http_status_to_code
 .. autofunction:: falcon.code_to_http_status
-.. autofunction:: falcon.get_http_status
 
 Media types
 -----------
diff --git a/docs/api/websocket.rst b/docs/api/websocket.rst
index 38878f96..6e632c52 100644
--- a/docs/api/websocket.rst
+++ b/docs/api/websocket.rst
@@ -3,8 +3,6 @@
 WebSocket (ASGI Only)
 =====================
 
-.. contents:: :local:
-
 Falcon builds upon the
 `ASGI WebSocket Specification <https://asgi.readthedocs.io/en/latest/specs/www.html#websocket>`_
 to provide a simple, no-nonsense WebSocket server implementation.
@@ -112,32 +110,36 @@ Lost Connections
 ----------------
 
 When the app attempts to receive a message from the client, the ASGI server
-emits a `disconnect` event if the connection has been lost for any reason. Falcon
-surfaces this event by raising an instance of :class:`~.WebSocketDisconnected`
-to the caller.
-
-On the other hand, the ASGI spec requires the ASGI server to silently consume
-messages sent by the app after the connection has been lost (i.e.,  it should
-not be considered an error). Therefore, an endpoint that primarily streams
-outbound events to the client might continue consuming resources unnecessarily
-for some time after the connection is lost.
+emits a ``disconnect`` event if the connection has been lost for any
+reason. Falcon surfaces this event by raising an instance of
+:class:`~.WebSocketDisconnected` to the caller.
+
+On the other hand, the ASGI spec previously required the ASGI server to
+silently consume messages sent by the app after the connection has been lost
+(i.e.,  it should not be considered an error). Therefore, an endpoint that
+primarily streams outbound events to the client could continue consuming
+resources unnecessarily for some time after the connection is lost.
+This aspect has been rectified in the ASGI HTTP spec version ``2.4``,
+and calling ``send()``  on a closed connection should now raise an
+error. Unfortunately, not all ASGI servers have adopted this new behavior
+uniformly yet.
 
 As a workaround, Falcon implements a small incoming message queue that is used
 to detect a lost connection and then raise an instance of
-:class:`~.WebSocketDisconnected` to the caller the next time it attempts to send
-a message.
-
-This workaround is only necessary when the app itself does not consume messages
-from the client often enough to quickly detect when the connection is lost.
-Otherwise, Falcon's receive queue can be disabled for a slight performance boost
-by setting :attr:`~falcon.asgi.WebSocketOptions.max_receive_queue` to ``0`` via
+:class:`~.WebSocketDisconnected` to the caller the next time it attempts to
+send a message.
+If your ASGI server of choice adheres to the spec version ``2.4``, this receive
+queue can be safely disabled for a slight performance boost by setting
+:attr:`~falcon.asgi.WebSocketOptions.max_receive_queue` to ``0`` via
 :attr:`~falcon.asgi.App.ws_options`.
-
-Note also that some ASGI server implementations do not strictly follow the ASGI
-spec in this regard, and in fact will raise an error when the app attempts to
-send a message after the client disconnects. If testing reveals this to be the
-case for your ASGI server of choice, Falcon's own receive queue can be safely
-disabled.
+(We may revise this setting, and disable the queue by default in the future if
+our testing indicates that all major ASGI servers have caught up with the
+spec.)
+
+Furthermore, even on non-compliant or older ASGI servers, this workaround is
+only necessary when the app itself does not consume messages from the client
+often enough to quickly detect when the connection is lost.
+Otherwise, Falcon's receive queue can also be disabled as described above.
 
 .. _ws_error_handling:
 
diff --git a/docs/conf.py b/docs/conf.py
index abd0a92c..492c0028 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,17 +1,23 @@
-# Falcon documentation build configuration file, created by
-# sphinx-quickstart on Wed Mar 12 14:14:02 2014.
+# Copyright 2014-2024 by Falcon Contributors.
 #
-# This file is execfile()d with the current directory set to its
-# containing dir.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
 #
-# Note that not all possible configuration values are present in this
-# autogenerated file.
+#    http://www.apache.org/licenses/LICENSE-2.0
 #
-# All configuration values have a default; values that are commented out
-# serve to show the default.
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
 
-from collections import OrderedDict
-from datetime import datetime
+# Configuration file for the Sphinx documentation builder.
+#
+# For the full list of built-in configuration values, see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+import datetime
 import multiprocessing
 import os
 import sys
@@ -20,6 +26,8 @@ sys.path.insert(0, os.path.abspath('..'))
 
 import falcon  # noqa: E402
 
+# -- Build tweaks -------------------------------------------------------------
+
 # NOTE(kgriffs): Work around the change in Python 3.8 that breaks sphinx
 #   on macOS. See also:
 #
@@ -29,11 +37,11 @@ import falcon  # noqa: E402
 if not sys.platform.startswith('win'):
     multiprocessing.set_start_method('fork')
 
-# on_rtd is whether we are on readthedocs.org
-on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
+# _on_rtd is whether we are on readthedocs.org
+# _on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
 
 # Used to alter sphinx configuration for the Dash documentation build
-dash_build = os.environ.get('DASHBUILD', False) == 'True'
+_dash_build = os.environ.get('DASHBUILD', False) == 'True'
 
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
@@ -41,24 +49,32 @@ dash_build = os.environ.get('DASHBUILD', False) == 'True'
 sys.path.insert(0, os.path.abspath('..'))
 sys.path.insert(0, os.path.abspath('.'))
 
-# Path to custom themes
-sys.path.append(os.path.abspath('_themes'))
+# -- Project information ------------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
 
-# -- General configuration ------------------------------------------------
+_version_components = falcon.__version__.split('.')
+_prerelease_version = any(
+    not component.isdigit() and not component.startswith('post')
+    for component in _version_components
+)
+
+
+project = 'Falcon'
+copyright = '{year} Falcon Contributors'.format(year=datetime.datetime.now().year)
+author = 'Kurt Griffiths et al.'
+version = '.'.join(_version_components[0:2])
+release = falcon.__version__
 
-# If your documentation needs a minimal Sphinx version, state it here.
-# needs_sphinx = '1.0'
+# -- General configuration ----------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
 
-# Add any Sphinx extension module names here, as strings. They can be
-# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
-# ones.
 extensions = [
-    'sphinx.ext.intersphinx',
     'sphinx.ext.autodoc',
-    'sphinx.ext.viewcode',
+    'sphinx.ext.intersphinx',
     'sphinx.ext.napoleon',
-    'sphinx_tabs.tabs',
-    'sphinx_tabs.tabs',
+    'sphinx.ext.viewcode',
+    'sphinx_copybutton',
+    'sphinx_design',
     'myst_parser',
     # Falcon-specific extensions
     'ext.cibuildwheel',
@@ -67,232 +83,99 @@ extensions = [
     'ext.rfc',
 ]
 
-# Add any paths that contain templates here, relative to this directory.
 templates_path = ['_templates']
-
-# The suffix of source filenames.
-source_suffix = '.rst'
-
-# The encoding of source files.
-# source_encoding = 'utf-8-sig'
-
-# The master toctree document.
-master_doc = 'index'
-
-# General information about the project.
-project = 'Falcon'
-copyright = '{year} Falcon Contributors'.format(year=datetime.now().year)
-
-# The version info for the project you're documenting, acts as replacement for
-# |version| and |release|, also used in various other places throughout the
-# built documents.
-
-_version_components = falcon.__version__.split('.')
-_prerelease = any(
-    not component.isdigit() and not component.startswith('post')
-    for component in _version_components
-)
-
-html_context = {'prerelease': _prerelease}
-
-# The short X.Y version.
-version = '.'.join(_version_components[0:2])
-
-# The full version, including alpha/beta/rc tags.
-release = falcon.__version__
-
-# The language for content autogenerated by Sphinx. Refer to documentation
-# for a list of supported languages.
-# language = None
-
-# There are two options for replacing |today|: either, you set today to some
-# non-false value, then it is used:
-# today = ''
-# Else, today_fmt is used as the format for a strftime call.
-# today_fmt = '%B %d, %Y'
-
-# List of patterns, relative to source directory, that match files and
-# directories to ignore when looking for source files.
 exclude_patterns = ['_build', '_newsfragments']
 
-# The reST default role (used for this markup: `text`) to use for all
-# documents.
-# default_role = None
-
-# If true, '()' will be appended to :func: etc. cross-reference text.
-# add_function_parentheses = True
-
-# If true, the current module name will be prepended to all description
-# unit titles (such as .. function::).
-# add_module_names = True
-
-# If true, sectionauthor and moduleauthor directives will be shown in the
-# output. They are ignored by default.
-# show_authors = False
-
 # The name of the Pygments (syntax highlighting) style to use.
-pygments_style = 'github'
-
-# A list of ignored prefixes for module index sorting.
-# modindex_common_prefix = []
-
-# If true, keep warnings as "system message" paragraphs in the built documents.
-# keep_warnings = False
-
-
-# -- Options for HTML output ----------------------------------------------
+# NOTE(vytas): The PyData theme uses separate Pygments style settings for HTML,
+#   so we specify a print-friendly theme here for the likes of latexpdf.
+pygments_style = 'bw'
 
-# Add any paths that contain custom themes here, relative to this directory.
-# html_theme_path = ['_themes']
-# html_theme = ''
-
-html_theme = 'alabaster'
-
-# if not on_rtd:
-#   # Use the RTD theme explicitly if it is available
-#   try:
-#     import sphinx_rtd_theme
-
-#     html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
-#     html_theme = "sphinx_rtd_theme"
-#   except ImportError:
-#     pass
-
-# Theme options are theme-specific and customize the look and feel of a theme
-# further.  For a list of options available for each theme, see the
-# documentation.
-html_theme_options = {
-    'page_width': '80%',
-    'body_max_width': '100%',
-    'github_user': 'falconry',
-    'github_repo': 'falcon',
-    'github_button': False,
-    'github_banner': False,
-    'fixed_sidebar': False,
-    'show_powered_by': False,
-    'extra_nav_links': OrderedDict(
-        [
-            ('Falcon Home', 'https://falconframework.org/'),
-            ('Falcon Wiki', 'https://github.com/falconry/falcon/wiki'),
-            ('GitHub Project', 'https://github.com/falconry/falcon'),
-            ('Get Help', '/community/help.html'),
-            (
-                'Support Falcon',
-                'https://falconframework.org/#sectionSupportFalconDevelopment',
-            ),
-        ]
-    ),
-}
-
-if dash_build:
-    html_theme_options.update(
-        {
-            'font_size': 13,
-        }
-    )
-
-# The name for this set of Sphinx documents.  If None, it defaults to
-# "<project> v<release> documentation".
-# html_title = None
-
-# A shorter title for the navigation bar.  Default is the same as html_title.
-# html_short_title = None
+# Intersphinx configuration
+intersphinx_mapping = {'python': ('https://docs.python.org/3', None)}
 
-# The name of an image file (relative to this directory) to place at the top
-# of the sidebar.
-# html_logo = '../falcon.png'
+# -- Options for HTML output --------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
 
-# The name of an image file (within the static path) to use as favicon of the
-# docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32
-# pixels large.
+html_css_files = ['custom.css']
+html_js_files = ['custom-icons.js']
 html_favicon = '_static/img/favicon.ico'
-
-# Add any paths that contain custom static files (such as style sheets) here,
-# relative to this directory. They are copied after the builtin static files,
-# so a file named "default.css" will overwrite the builtin "default.css".
+html_logo = '_static/img/logo.svg'
 html_static_path = ['_static']
-
-# Add any extra paths that contain custom files (such as robots.txt or
-# .htaccess) here, relative to this directory. These files are copied
-# directly to the root of the documentation.
-# html_extra_path = []
-
-# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
-# using the given strftime format.
-# html_last_updated_fmt = '%b %d, %Y'
-
-# If true, SmartyPants will be used to convert quotes and dashes to
-# typographically correct entities.
-# html_use_smartypants = True
-
-# Custom sidebar templates, maps document names to template names.
-# html_sidebars = {
-#     'index':    ['side-primary.html', 'searchbox.html'],
-#     '**':       ['side-secondary.html', 'localtoc.html',
-#                  'relations.html', 'searchbox.html']
-# }
-
-html_sidebars = {
-    '**': [
-        'sidebar-top.html',
-        'sidebar-sponsors.html',
-        'about.html',
-        'navigation.html',
-        'relations.html',
-        'searchbox.html',
-    ]
-    if not dash_build
-    else []
+html_theme = 'pydata_sphinx_theme'
+html_show_sourcelink = False
+
+html_context = {
+    # NOTE(vytas): We don't provide any default, the browser's preference
+    # should be used.
+    # 'default_mode': 'light',
+    'prerelease': _prerelease_version,  # True if tag is not the empty string
 }
 
-# Additional templates that should be rendered to pages, maps page names to
-# template names.
-# html_additional_pages = {}
-
-# If false, no module index is generated.
-# html_domain_indices = True
-
-# If false, no index is generated.
-# html_use_index = True
+# Theme options are theme-specific and customize the look and feel further.
+# https://pydata-sphinx-theme.readthedocs.io/en/stable/user_guide/index.html
 
-# If true, the index is split into individual pages for each letter.
-# html_split_index = False
-
-# If true, links to the reST sources are added to the pages.
-# html_show_sourcelink = True
-
-# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
-# html_show_sphinx = True
-
-# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
-html_show_copyright = True
-
-# If true, an OpenSearch description file will be output, and all pages will
-# contain a <link> tag referring to it.  The value of this option must be the
-# base URL from which the finished HTML is served.
-# html_use_opensearch = ''
+html_theme_options = {
+    'pygments_light_style': 'falconry-light',
+    'pygments_dark_style': 'falconry-dark',
+    'header_links_before_dropdown': 4,
+    'external_links': [
+        {
+            'name': 'Get Help',
+            'url': 'https://falcon.readthedocs.io/community/help.html',
+        },
+        {'name': 'Falcon Wiki', 'url': 'https://github.com/falconry/falcon/wiki'},
+        {
+            'name': 'Support Falcon',
+            'url': 'https://falconframework.org/#sectionSupportFalconDevelopment',
+        },
+    ],
+    'icon_links': [
+        {
+            'name': 'GitHub',
+            'url': 'https://github.com/falconry/falcon',
+            'icon': 'fa-brands fa-github',
+        },
+        {
+            'name': 'PyPI',
+            'url': 'https://pypi.org/project/falcon',
+            'icon': 'fa-custom fa-pypi',
+        },
+        {
+            'name': 'Falcon Home',
+            'url': 'https://falconframework.org',
+            'icon': 'fa-custom fa-falcon',
+        },
+    ],
+    # NOTE(vytas): Use only light theme for now.
+    #   Add `theme-switcher` below to resurrect the dark option.
+    'logo': {
+        'text': 'Falcon',
+        # "image_dark": "_static/img/logo.svg",
+    },
+    'navbar_end': ['theme-switcher', 'navbar-icon-links'],
+    'footer_start': ['copyright'],
+    'footer_end': ['theme-version'],
+}
 
-# This is the file name suffix for HTML files (e.g. ".xhtml").
-# html_file_suffix = None
+if _dash_build:
+    html_theme_options.update(font_size=13)
 
-# Output file base name for HTML help builder.
-htmlhelp_basename = 'Falcondoc'
 
+# -- Options for LaTeX output -------------------------------------------------
 
-# -- Options for LaTeX output ---------------------------------------------
+# NOTE(vytas): The default engine fails to build citing unsupported Unicode
+# characters.
+latex_engine = 'xelatex'
 
 latex_elements = {
-    # The paper size ('letterpaper' or 'a4paper').
-    # 'papersize': 'letterpaper',
+    'papersize': 'a4paper',
     # The font size ('10pt', '11pt' or '12pt').
     # 'pointsize': '10pt',
     # Additional stuff for the LaTeX preamble.
     # 'preamble': '',
 }
 
-# Grouping the document tree into LaTeX files. List of tuples
-# (source start file, target name, title,
-#  author, documentclass [howto, manual, or own class]).
 latex_documents = [
     (
         'index',
@@ -303,42 +186,12 @@ latex_documents = [
     ),
 ]
 
-# The name of an image file (relative to this directory) to place at the top of
-# the title page.
-# latex_logo = None
-
-# For "manual" documents, if this is true, then toplevel headings are parts,
-# not chapters.
-# latex_use_parts = False
-
-# If true, show page references after internal links.
-# latex_show_pagerefs = False
-
-# If true, show URL addresses after external links.
-# latex_show_urls = False
-
-# Documents to append as an appendix to all manuals.
-# latex_appendices = []
-
-# If false, no module index is generated.
-# latex_domain_indices = True
+# -- Options for manual page output -------------------------------------------
 
-
-# -- Options for manual page output ---------------------------------------
-
-# One entry per manual page. List of tuples
-# (source start file, name, description, authors, manual section).
 man_pages = [('index', 'falcon', 'Falcon Documentation', ['Kurt Griffiths et al.'], 1)]
 
-# If true, show URL addresses after external links.
-# man_show_urls = False
-
+# -- Options for Texinfo output -----------------------------------------------
 
-# -- Options for Texinfo output -------------------------------------------
-
-# Grouping the document tree into Texinfo files. List of tuples
-# (source start file, target name, title, author,
-#  dir menu entry, description, category)
 texinfo_documents = [
     (
         'index',
@@ -350,18 +203,3 @@ texinfo_documents = [
         'Miscellaneous',
     ),
 ]
-
-# Documents to append as an appendix to all manuals.
-# texinfo_appendices = []
-
-# If false, no module index is generated.
-# texinfo_domain_indices = True
-
-# How to display URL addresses: 'footnote', 'no', or 'inline'.
-# texinfo_show_urls = 'footnote'
-
-# If true, do not generate a @detailmenu in the "Top" node's menu.
-# texinfo_no_detailmenu = False
-
-# Example configuration for intersphinx: refer to the Python standard library.
-intersphinx_mapping = {'python': ('https://docs.python.org/3', None)}
diff --git a/docs/index.rst b/docs/index.rst
index 8e3ac1f0..359062b5 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -21,7 +21,6 @@ while remaining highly effective.
     import falcon
 
     class QuoteResource:
-
         def on_get(self, req, resp):
             """Handles GET requests"""
             quote = {
@@ -128,7 +127,7 @@ Documentation
    :maxdepth: 3
 
    user/index
-   deploy/index
    community/index
    api/index
    changes/index
+   deploy/index
diff --git a/docs/user/faq.rst b/docs/user/faq.rst
index 11a394b6..56826fbe 100644
--- a/docs/user/faq.rst
+++ b/docs/user/faq.rst
@@ -3,8 +3,6 @@
 FAQ
 ===
 
-.. contents:: :local:
-
 Design Philosophy
 ~~~~~~~~~~~~~~~~~
 
@@ -689,9 +687,10 @@ The `stream` of a body part is a file-like object implementing the ``read()``
 method, making it compatible with ``boto3``\'s
 `upload_fileobj <https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.upload_fileobj>`_:
 
-.. tabs::
+.. tab-set::
 
-    .. group-tab:: WSGI
+    .. tab-item:: WSGI
+        :sync: wsgi
 
         .. code:: python
 
@@ -705,7 +704,8 @@ method, making it compatible with ``boto3``\'s
                 if part.name == 'myfile':
                     s3.upload_fileobj(part.stream, 'mybucket', 'mykey')
 
-    .. group-tab:: ASGI
+    .. tab-item:: ASGI
+        :sync: asgi
 
         .. code:: python
 
diff --git a/docs/user/quickstart.rst b/docs/user/quickstart.rst
index 43157ac9..38f0f88c 100644
--- a/docs/user/quickstart.rst
+++ b/docs/user/quickstart.rst
@@ -13,9 +13,10 @@ Learning by Example
 Here is a simple example from Falcon's README, showing how to get
 started writing an app.
 
-.. tabs::
+.. tab-set::
 
-    .. group-tab:: WSGI
+    .. tab-item:: WSGI
+        :sync: wsgi
 
         .. literalinclude:: ../../examples/things.py
             :language: python
@@ -41,7 +42,8 @@ started writing an app.
             $ pip install --upgrade httpie
             $ http localhost:8000/things
 
-    .. group-tab:: ASGI
+    .. tab-item:: ASGI
+        :sync: asgi
 
         .. literalinclude:: ../../examples/things_asgi.py
             :language: python
@@ -75,9 +77,10 @@ A More Complex Example
 Here is a more involved example that demonstrates reading headers and query
 parameters, handling errors, and working with request and response bodies.
 
-.. tabs::
+.. tab-set::
 
-    .. group-tab:: WSGI
+    .. tab-item:: WSGI
+        :sync: wsgi
 
         Note that this example assumes that the
         `requests <https://pypi.org/project/requests/>`_ package has been installed.
@@ -135,7 +138,8 @@ parameters, handling errors, and working with request and response bodies.
             • Error handlers:
                 ⇜ StorageError handle
 
-    .. group-tab:: ASGI
+    .. tab-item:: ASGI
+        :sync: asgi
 
         Note that this example requires the
         `httpx <https://pypi.org/project/httpx/>`_ package in lieu of
diff --git a/docs/user/recipes/output-csv.rst b/docs/user/recipes/output-csv.rst
index 42556253..8f3f8b94 100644
--- a/docs/user/recipes/output-csv.rst
+++ b/docs/user/recipes/output-csv.rst
@@ -9,14 +9,16 @@ file is a fairly common back-end service task.
 The easiest approach is to simply write CSV rows to an ``io.StringIO`` stream,
 and then assign its value to :attr:`resp.text <falcon.Response.text>`:
 
-.. tabs::
+.. tab-set::
 
-    .. group-tab:: WSGI
+    .. tab-item:: WSGI
+        :sync: wsgi
 
         .. literalinclude:: ../../../examples/recipes/output_csv_text_wsgi.py
             :language: python
 
-    .. group-tab:: ASGI
+    .. tab-item:: ASGI
+        :sync: asgi
 
         .. literalinclude:: ../../../examples/recipes/output_csv_text_asgi.py
             :language: python
@@ -38,14 +40,16 @@ our own pseudo stream object. Our stream's ``write()`` method will simply
 accumulate the CSV data in a list. We will then set :attr:`resp.stream
 <falcon.Response.stream>` to a generator yielding data chunks from this list:
 
-.. tabs::
+.. tab-set::
 
-    .. group-tab:: WSGI
+    .. tab-item:: WSGI
+        :sync: wsgi
 
         .. literalinclude:: ../../../examples/recipes/output_csv_stream_wsgi.py
             :language: python
 
-    .. group-tab:: ASGI
+    .. tab-item:: ASGI
+        :sync: asgi
 
         .. literalinclude:: ../../../examples/recipes/output_csv_stream_wsgi.py
             :language: python
diff --git a/e2e-tests/server/app.py b/e2e-tests/server/app.py
index be955898..61f6dc4a 100644
--- a/e2e-tests/server/app.py
+++ b/e2e-tests/server/app.py
@@ -15,6 +15,10 @@ STATIC = HERE.parent / 'static'
 def create_app() -> falcon.asgi.App:
     app = falcon.asgi.App()
 
+    # NOTE(vytas): E2E tests run Uvicorn, and the latest versions support ASGI
+    #   HTTP/WSspec ver 2.4, so buffering on our side should not be needed.
+    app.ws_options.max_receive_queue = 0
+
     hub = Hub()
     app.add_route('/ping', Pong())
     app.add_route('/sse', Events(hub))
diff --git a/falcon/__init__.py b/falcon/__init__.py
index b9976b64..542323b3 100644
--- a/falcon/__init__.py
+++ b/falcon/__init__.py
@@ -77,7 +77,6 @@ __all__ = (
     'ETag',
     'get_argnames',
     'get_bound_method',
-    'get_http_status',
     'get_running_loop',
     'http_cookies',
     'http_date_to_dt',
@@ -154,6 +153,7 @@ __all__ = (
     'HTTP_100',
     'HTTP_101',
     'HTTP_102',
+    'HTTP_103',
     'HTTP_200',
     'HTTP_201',
     'HTTP_202',
@@ -191,9 +191,11 @@ __all__ = (
     'HTTP_416',
     'HTTP_417',
     'HTTP_418',
+    'HTTP_421',
     'HTTP_422',
     'HTTP_423',
     'HTTP_424',
+    'HTTP_425',
     'HTTP_426',
     'HTTP_428',
     'HTTP_429',
@@ -205,8 +207,10 @@ __all__ = (
     'HTTP_503',
     'HTTP_504',
     'HTTP_505',
+    'HTTP_506',
     'HTTP_507',
     'HTTP_508',
+    'HTTP_510',
     'HTTP_511',
     'HTTP_701',
     'HTTP_702',
@@ -262,6 +266,7 @@ __all__ = (
     'HTTP_CONFLICT',
     'HTTP_CONTINUE',
     'HTTP_CREATED',
+    'HTTP_EARLY_HINTS',
     'HTTP_EXPECTATION_FAILED',
     'HTTP_FAILED_DEPENDENCY',
     'HTTP_FORBIDDEN',
@@ -277,12 +282,14 @@ __all__ = (
     'HTTP_LOCKED',
     'HTTP_LOOP_DETECTED',
     'HTTP_METHOD_NOT_ALLOWED',
+    'HTTP_MISDIRECTED_REQUEST',
     'HTTP_MOVED_PERMANENTLY',
     'HTTP_MULTIPLE_CHOICES',
     'HTTP_MULTI_STATUS',
     'HTTP_NETWORK_AUTHENTICATION_REQUIRED',
     'HTTP_NON_AUTHORITATIVE_INFORMATION',
     'HTTP_NOT_ACCEPTABLE',
+    'HTTP_NOT_EXTENDED',
     'HTTP_NOT_FOUND',
     'HTTP_NOT_IMPLEMENTED',
     'HTTP_NOT_MODIFIED',
@@ -305,6 +312,7 @@ __all__ = (
     'HTTP_SERVICE_UNAVAILABLE',
     'HTTP_SWITCHING_PROTOCOLS',
     'HTTP_TEMPORARY_REDIRECT',
+    'HTTP_TOO_EARLY',
     'HTTP_TOO_MANY_REQUESTS',
     'HTTP_UNAUTHORIZED',
     'HTTP_UNAVAILABLE_FOR_LEGAL_REASONS',
@@ -312,6 +320,7 @@ __all__ = (
     'HTTP_UNSUPPORTED_MEDIA_TYPE',
     'HTTP_UPGRADE_REQUIRED',
     'HTTP_USE_PROXY',
+    'HTTP_VARIANT_ALSO_NEGOTIATES',
 )
 
 # NOTE(kgriffs,vytas): Hoist classes and functions into the falcon namespace.
@@ -409,6 +418,7 @@ from falcon.response import ResponseOptions
 from falcon.status_codes import HTTP_100
 from falcon.status_codes import HTTP_101
 from falcon.status_codes import HTTP_102
+from falcon.status_codes import HTTP_103
 from falcon.status_codes import HTTP_200
 from falcon.status_codes import HTTP_201
 from falcon.status_codes import HTTP_202
@@ -446,9 +456,11 @@ from falcon.status_codes import HTTP_415
 from falcon.status_codes import HTTP_416
 from falcon.status_codes import HTTP_417
 from falcon.status_codes import HTTP_418
+from falcon.status_codes import HTTP_421
 from falcon.status_codes import HTTP_422
 from falcon.status_codes import HTTP_423
 from falcon.status_codes import HTTP_424
+from falcon.status_codes import HTTP_425
 from falcon.status_codes import HTTP_426
 from falcon.status_codes import HTTP_428
 from falcon.status_codes import HTTP_429
@@ -460,8 +472,10 @@ from falcon.status_codes import HTTP_502
 from falcon.status_codes import HTTP_503
 from falcon.status_codes import HTTP_504
 from falcon.status_codes import HTTP_505
+from falcon.status_codes import HTTP_506
 from falcon.status_codes import HTTP_507
 from falcon.status_codes import HTTP_508
+from falcon.status_codes import HTTP_510
 from falcon.status_codes import HTTP_511
 from falcon.status_codes import HTTP_701
 from falcon.status_codes import HTTP_702
@@ -517,6 +531,7 @@ from falcon.status_codes import HTTP_BAD_REQUEST
 from falcon.status_codes import HTTP_CONFLICT
 from falcon.status_codes import HTTP_CONTINUE
 from falcon.status_codes import HTTP_CREATED
+from falcon.status_codes import HTTP_EARLY_HINTS
 from falcon.status_codes import HTTP_EXPECTATION_FAILED
 from falcon.status_codes import HTTP_FAILED_DEPENDENCY
 from falcon.status_codes import HTTP_FORBIDDEN
@@ -532,6 +547,7 @@ from falcon.status_codes import HTTP_LENGTH_REQUIRED
 from falcon.status_codes import HTTP_LOCKED
 from falcon.status_codes import HTTP_LOOP_DETECTED
 from falcon.status_codes import HTTP_METHOD_NOT_ALLOWED
+from falcon.status_codes import HTTP_MISDIRECTED_REQUEST
 from falcon.status_codes import HTTP_MOVED_PERMANENTLY
 from falcon.status_codes import HTTP_MULTI_STATUS
 from falcon.status_codes import HTTP_MULTIPLE_CHOICES
@@ -539,6 +555,7 @@ from falcon.status_codes import HTTP_NETWORK_AUTHENTICATION_REQUIRED
 from falcon.status_codes import HTTP_NO_CONTENT
 from falcon.status_codes import HTTP_NON_AUTHORITATIVE_INFORMATION
 from falcon.status_codes import HTTP_NOT_ACCEPTABLE
+from falcon.status_codes import HTTP_NOT_EXTENDED
 from falcon.status_codes import HTTP_NOT_FOUND
 from falcon.status_codes import HTTP_NOT_IMPLEMENTED
 from falcon.status_codes import HTTP_NOT_MODIFIED
@@ -560,6 +577,7 @@ from falcon.status_codes import HTTP_SEE_OTHER
 from falcon.status_codes import HTTP_SERVICE_UNAVAILABLE
 from falcon.status_codes import HTTP_SWITCHING_PROTOCOLS
 from falcon.status_codes import HTTP_TEMPORARY_REDIRECT
+from falcon.status_codes import HTTP_TOO_EARLY
 from falcon.status_codes import HTTP_TOO_MANY_REQUESTS
 from falcon.status_codes import HTTP_UNAUTHORIZED
 from falcon.status_codes import HTTP_UNAVAILABLE_FOR_LEGAL_REASONS
@@ -567,6 +585,7 @@ from falcon.status_codes import HTTP_UNPROCESSABLE_ENTITY
 from falcon.status_codes import HTTP_UNSUPPORTED_MEDIA_TYPE
 from falcon.status_codes import HTTP_UPGRADE_REQUIRED
 from falcon.status_codes import HTTP_USE_PROXY
+from falcon.status_codes import HTTP_VARIANT_ALSO_NEGOTIATES
 from falcon.stream import BoundedStream
 
 # NOTE(kgriffs): Ensure that "from falcon import uri" will import
@@ -589,7 +608,6 @@ from falcon.util import dt_to_http
 from falcon.util import ETag
 from falcon.util import get_argnames
 from falcon.util import get_bound_method
-from falcon.util import get_http_status
 from falcon.util import get_running_loop
 from falcon.util import http_cookies
 from falcon.util import http_date_to_dt
diff --git a/falcon/app.py b/falcon/app.py
index 71db45c2..56ba3528 100644
--- a/falcon/app.py
+++ b/falcon/app.py
@@ -26,7 +26,6 @@ from typing import (
     ClassVar,
     Dict,
     FrozenSet,
-    IO,
     Iterable,
     List,
     Literal,
@@ -62,6 +61,7 @@ from falcon.typing import ErrorHandler
 from falcon.typing import ErrorSerializer
 from falcon.typing import FindMethod
 from falcon.typing import ProcessResponseMethod
+from falcon.typing import ReadableIO
 from falcon.typing import ResponderCallable
 from falcon.typing import SinkCallable
 from falcon.typing import SinkPrefix
@@ -1200,7 +1200,9 @@ class App:
     def _get_body(
         self,
         resp: Response,
-        wsgi_file_wrapper: Optional[Callable[[IO[bytes], int], Iterable[bytes]]] = None,
+        wsgi_file_wrapper: Optional[
+            Callable[[ReadableIO, int], Iterable[bytes]]
+        ] = None,
     ) -> Tuple[Iterable[bytes], Optional[int]]:
         """Convert resp content into an iterable as required by PEP 333.
 
@@ -1238,11 +1240,13 @@ class App:
                     # TODO(kgriffs): Make block size configurable at the
                     # global level, pending experimentation to see how
                     # useful that would be. See also the discussion on
-                    # this GitHub PR: http://goo.gl/XGrtDz
-                    iterable = wsgi_file_wrapper(stream, self._STREAM_BLOCK_SIZE)
+                    # this GitHub PR:
+                    # https://github.com/falconry/falcon/pull/249#discussion_r11269730
+                    iterable = wsgi_file_wrapper(stream, self._STREAM_BLOCK_SIZE)  # type: ignore[arg-type]
                 else:
                     iterable = helpers.CloseableStreamIterator(
-                        stream, self._STREAM_BLOCK_SIZE
+                        stream,  # type: ignore[arg-type]
+                        self._STREAM_BLOCK_SIZE,
                     )
             else:
                 iterable = stream
@@ -1259,7 +1263,7 @@ class App:
 
 
 # TODO(myusko): This class is a compatibility alias, and should be removed
-# in the next major release (4.0).
+# in Falcon 5.0.
 class API(App):
     """Compatibility alias of :class:`falcon.App`.
 
@@ -1267,12 +1271,12 @@ class API(App):
     reflect the breadth of applications that :class:`App <falcon.App>`, and its
     ASGI counterpart in particular, can now be used for.
 
-    This compatibility alias should be considered deprecated; it will be
-    removed in a future release.
+    This compatibility alias is deprecated; it will be removed entirely in
+    Falcon 5.0.
     """
 
     @deprecation.deprecated(
-        'API class may be removed in a future release, use falcon.App instead.'
+        'The API class will be removed in Falcon 5.0, use falcon.App instead.'
     )
     def __init__(self, *args: Any, **kwargs: Any) -> None:
         super().__init__(*args, **kwargs)
diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py
index 4fe7b7db..9b69eb58 100644
--- a/falcon/asgi/app.py
+++ b/falcon/asgi/app.py
@@ -46,7 +46,6 @@ from falcon.app_helpers import prepare_middleware_ws
 from falcon.asgi_spec import AsgiSendMsg
 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
@@ -60,6 +59,7 @@ from falcon.typing import AsgiResponderCallable
 from falcon.typing import AsgiResponderWsCallable
 from falcon.typing import AsgiSend
 from falcon.typing import AsgiSinkCallable
+from falcon.typing import MISSING
 from falcon.typing import SinkPrefix
 from falcon.util import get_argnames
 from falcon.util.misc import is_python_func
@@ -552,9 +552,9 @@ class App(falcon.app.App):
                     data = resp._data
 
                     if data is None and resp._media is not None:
-                        # NOTE(kgriffs): We use a special _UNSET singleton since
+                        # NOTE(kgriffs): We use a special MISSING singleton since
                         #   None is ambiguous (the media handler might return None).
-                        if resp._media_rendered is _UNSET:
+                        if resp._media_rendered is MISSING:
                             opt = resp.options
                             if not resp.content_type:
                                 resp.content_type = opt.default_media_type
@@ -577,7 +577,7 @@ class App(falcon.app.App):
                         data = text.encode()
                     except AttributeError:
                         # NOTE(kgriffs): Assume it was a bytes object already
-                        data = text
+                        data = text  # type: ignore[assignment]
 
             else:
                 # NOTE(vytas): Custom response type.
@@ -1028,9 +1028,9 @@ class App(falcon.app.App):
 
         loop = asyncio.get_running_loop()
 
-        for cb, is_async in callbacks:  # type: ignore[attr-defined]
+        for cb, is_async in callbacks or ():
             if is_async:
-                loop.create_task(cb())
+                loop.create_task(cb())  # type: ignore[arg-type]
             else:
                 loop.run_in_executor(None, cb)
 
@@ -1058,7 +1058,7 @@ class App(falcon.app.App):
                     )
                     return
 
-                if self.req_options.auto_parse_form_urlencoded:
+                if self.req_options._auto_parse_form_urlencoded:
                     await send(
                         {
                             'type': EventType.LIFESPAN_STARTUP_FAILED,
diff --git a/falcon/asgi/request.py b/falcon/asgi/request.py
index f4f4f1d7..5fb62288 100644
--- a/falcon/asgi/request.py
+++ b/falcon/asgi/request.py
@@ -41,7 +41,7 @@ from falcon.typing import AsgiReceive
 from falcon.typing import MISSING
 from falcon.typing import MissingOr
 from falcon.typing import StoreArgument
-from falcon.util import deprecated
+from falcon.util import deprecation
 from falcon.util import ETag
 from falcon.util.uri import parse_host
 from falcon.util.uri import parse_query_string
@@ -348,8 +348,12 @@ class Request(request.Request):
         return ''
 
     @property
-    # NOTE(caselit): Deprecated long ago. Warns since 4.0
-    @deprecated('Use `root_path` instead', is_property=True)
+    # NOTE(caselit): Deprecated long ago. Warns since 4.0.
+    @deprecation.deprecated(
+        'Use `root_path` instead. '
+        '(This compatibility alias will be removed in Falcon 5.0.)',
+        is_property=True,
+    )
     def app(self) -> str:
         """Deprecated alias for :attr:`root_path`."""
         return self.root_path
diff --git a/falcon/asgi/response.py b/falcon/asgi/response.py
index b545b520..73fcfbfb 100644
--- a/falcon/asgi/response.py
+++ b/falcon/asgi/response.py
@@ -14,11 +14,18 @@
 
 """ASGI Response class."""
 
+from __future__ import annotations
+
 from inspect import iscoroutine
 from inspect import iscoroutinefunction
+from typing import Awaitable, Callable, List, Literal, Optional, Tuple, Union
 
 from falcon import response
-from falcon.constants import _UNSET
+from falcon.typing import AsyncIterator
+from falcon.typing import AsyncReadableIO
+from falcon.typing import MISSING
+from falcon.typing import ResponseCallbacks
+from falcon.typing import SseEmitter
 from falcon.util.misc import _encode_items_to_latin1
 from falcon.util.misc import is_python_func
 
@@ -33,184 +40,109 @@ class Response(response.Response):
 
     Keyword Arguments:
         options (dict): Set of global options passed from the App handler.
+    """
 
-    Attributes:
-        status (Union[str,int]): HTTP status code or line (e.g., ``'200 OK'``).
-            This may be set to a member of :class:`http.HTTPStatus`, an HTTP
-            status line string or byte string (e.g., ``'200 OK'``), or an
-            ``int``.
-
-            Note:
-                The Falcon framework itself provides a number of constants for
-                common status codes. They all start with the ``HTTP_`` prefix,
-                as in: ``falcon.HTTP_204``. (See also: :ref:`status`.)
-
-        status_code (int): HTTP status code normalized from :attr:`status`.
-            When a code is assigned to this property, :attr:`status` is updated,
-            and vice-versa. The status code can be useful when needing to check
-            in middleware for codes that fall into a certain class, e.g.::
-
-                if resp.status_code >= 400:
-                    log.warning(f'returning error response: {resp.status_code}')
-
-        media (object): A serializable object supported by the media handlers
-            configured via :class:`falcon.RequestOptions`.
-
-            Note:
-                See also :ref:`media` for more information regarding media
-                handling.
-
-        text (str): String representing response content.
-
-            Note:
-                Falcon will encode the given text as UTF-8
-                in the response. If the content is already a byte string,
-                use the :attr:`data` attribute instead (it's faster).
-
-        data (bytes): Byte string representing response content.
-
-            Use this attribute in lieu of `text` when your content is
-            already a byte string (of type ``bytes``).
-
-            Warning:
-                Always use the `text` attribute for text, or encode it
-                first to ``bytes`` when using the `data` attribute, to
-                ensure Unicode characters are properly encoded in the
-                HTTP response.
-
-        stream: An async iterator or generator that yields a series of
-            byte strings that will be streamed to the ASGI server as a
-            series of "http.response.body" events. Falcon will assume the
-            body is complete when the iterable is exhausted or as soon as it
-            yields ``None`` rather than an instance of ``bytes``::
-
-                async def producer():
-                    while True:
-                        data_chunk = await read_data()
-                        if not data_chunk:
-                            break
-
-                        yield data_chunk
-
-                resp.stream = producer
-
-            Alternatively, a file-like object may be used as long as it
-            implements an awaitable ``read()`` method::
-
-                resp.stream = await aiofiles.open('resp_data.bin', 'rb')
-
-            If the object assigned to :attr:`~.stream` holds any resources
-            (such as a file handle) that must be explicitly released, the
-            object must implement a ``close()`` method. The ``close()`` method
-            will be called after exhausting the iterable or file-like object.
-
-            Note:
-                In order to be compatible with Python 3.7+ and PEP 479,
-                async iterators must return ``None`` instead of raising
-                :class:`StopIteration`. This requirement does not
-                apply to async generators (PEP 525).
-
-            Note:
-                If the stream length is known in advance, you may wish to
-                also set the Content-Length header on the response.
-
-        sse (coroutine): A Server-Sent Event (SSE) emitter, implemented as
-            an async iterator or generator that yields a series of
-            of :class:`falcon.asgi.SSEvent` instances. Each event will be
-            serialized and sent to the client as HTML5 Server-Sent Events::
+    # PERF(kgriffs): These will be shadowed when set on an instance; let's
+    #   us avoid having to implement __init__ and incur the overhead of
+    #   an additional function call.
+    _sse: Optional[SseEmitter] = None
+    _registered_callbacks: Optional[List[ResponseCallbacks]] = None
 
-                async def emitter():
-                    while True:
-                        some_event = await get_next_event()
+    stream: Union[AsyncReadableIO, AsyncIterator[bytes], None]  # type: ignore[assignment]
+    """An async iterator or generator that yields a series of
+    byte strings that will be streamed to the ASGI server as a
+    series of "http.response.body" events. Falcon will assume the
+    body is complete when the iterable is exhausted or as soon as it
+    yields ``None`` rather than an instance of ``bytes``::
 
-                        if not some_event:
-                            # Send an event consisting of a single "ping"
-                            #   comment to keep the connection alive.
-                            yield SSEvent()
+        async def producer():
+            while True:
+                data_chunk = await read_data()
+                if not data_chunk:
+                    break
 
-                            # Alternatively, one can simply yield None and
-                            #   a "ping" will also be sent as above.
+                yield data_chunk
 
-                            # yield
+        resp.stream = producer
 
-                            continue
+    Alternatively, a file-like object may be used as long as it
+    implements an awaitable ``read()`` method::
 
-                        yield SSEvent(json=some_event, retry=5000)
+        resp.stream = await aiofiles.open('resp_data.bin', 'rb')
 
-                        # ...or
+    If the object assigned to :attr:`~.stream` holds any resources
+    (such as a file handle) that must be explicitly released, the
+    object must implement a ``close()`` method. The ``close()`` method
+    will be called after exhausting the iterable or file-like object.
 
-                        yield SSEvent(data=b'something', event_id=some_id)
+    Note:
+        In order to be compatible with Python 3.7+ and PEP 479,
+        async iterators must return ``None`` instead of raising
+        :class:`StopIteration`. This requirement does not
+        apply to async generators (PEP 525).
 
-                        # Alternatively, you may yield anything that implements
-                        #   a serialize() method that returns a byte string
-                        #   conforming to the SSE event stream format.
+    Note:
+        If the stream length is known in advance, you may wish to
+        also set the Content-Length header on the response.
+    """
 
-                        # yield some_event
+    @property
+    def sse(self) -> Optional[SseEmitter]:
+        """A Server-Sent Event (SSE) emitter, implemented as
+        an async iterator or generator that yields a series of
+        of :class:`falcon.asgi.SSEvent` instances. Each event will be
+        serialized and sent to the client as HTML5 Server-Sent Events::
 
-                resp.sse = emitter()
+            async def emitter():
+                while True:
+                    some_event = await get_next_event()
 
-            Note:
-                When the `sse` property is set, it supersedes both the
-                `text` and `data` properties.
+                    if not some_event:
+                        # Send an event consisting of a single "ping"
+                        #   comment to keep the connection alive.
+                        yield SSEvent()
 
-            Note:
-                When hosting an app that emits Server-Sent Events, the web
-                server should be set with a relatively long keep-alive TTL to
-                minimize the overhead of connection renegotiations.
+                        # Alternatively, one can simply yield None and
+                        #   a "ping" will also be sent as above.
 
-        context (object): Empty object to hold any data (in its attributes)
-            about the response which is specific to your app (e.g. session
-            object). Falcon itself will not interact with this attribute after
-            it has been initialized.
+                        # yield
 
-            Note:
-                The preferred way to pass response-specific data, when using the
-                default context type, is to set attributes directly on the
-                `context` object. For example::
+                        continue
 
-                    resp.context.cache_strategy = 'lru'
+                    yield SSEvent(json=some_event, retry=5000)
 
-        context_type (class): Class variable that determines the factory or
-            type to use for initializing the `context` attribute. By default,
-            the framework will instantiate bare objects (instances of the bare
-            :class:`falcon.Context` class). However, you may override this
-            behavior by creating a custom child class of
-            :class:`falcon.asgi.Response`, and then passing that new class
-            to ``falcon.App()`` by way of the latter's `response_type`
-            parameter.
+                    # ...or
 
-            Note:
-                When overriding `context_type` with a factory function (as
-                opposed to a class), the function is called like a method of
-                the current Response instance. Therefore the first argument is
-                the Response instance itself (self).
+                    yield SSEvent(data=b'something', event_id=some_id)
 
-        options (dict): Set of global options passed in from the App handler.
+                    # Alternatively, you may yield anything that implements
+                    #   a serialize() method that returns a byte string
+                    #   conforming to the SSE event stream format.
 
-        headers (dict): Copy of all headers set for the response,
-            sans cookies. Note that a new copy is created and returned each
-            time this property is referenced.
+                    # yield some_event
 
-        complete (bool): Set to ``True`` from within a middleware method to
-            signal to the framework that request processing should be
-            short-circuited (see also :ref:`Middleware <middleware>`).
-    """
+            resp.sse = emitter()
 
-    # PERF(kgriffs): These will be shadowed when set on an instance; let's
-    #   us avoid having to implement __init__ and incur the overhead of
-    #   an additional function call.
-    _sse = None
-    _registered_callbacks = None
+        Note:
+            When the `sse` property is set, it supersedes both the
+            `text` and `data` properties.
 
-    @property
-    def sse(self):
+        Note:
+            When hosting an app that emits Server-Sent Events, the web
+            server should be set with a relatively long keep-alive TTL to
+            minimize the overhead of connection renegotiations.
+        """  # noqa: D400 D205
         return self._sse
 
     @sse.setter
-    def sse(self, value):
+    def sse(self, value: Optional[SseEmitter]) -> None:
         self._sse = value
 
-    def set_stream(self, stream, content_length):
+    def set_stream(
+        self,
+        stream: Union[AsyncReadableIO, AsyncIterator[bytes]],  # type: ignore[override]
+        content_length: int,
+    ) -> None:
         """Set both `stream` and `content_length`.
 
         Although the :attr:`~falcon.asgi.Response.stream` and
@@ -241,7 +173,7 @@ class Response(response.Response):
         #   the self.content_length property.
         self._headers['content-length'] = str(content_length)
 
-    async def render_body(self):
+    async def render_body(self) -> Optional[bytes]:  # type: ignore[override]
         """Get the raw bytestring content for the response body.
 
         This coroutine can be awaited to get the raw data for the
@@ -261,14 +193,15 @@ class Response(response.Response):
 
         # NOTE(vytas): The code below is also inlined in asgi.App.__call__.
 
+        data: Optional[bytes]
         text = self.text
         if text is None:
             data = self._data
 
             if data is None and self._media is not None:
-                # NOTE(kgriffs): We use a special _UNSET singleton since
+                # NOTE(kgriffs): We use a special MISSING singleton since
                 #   None is ambiguous (the media handler might return None).
-                if self._media_rendered is _UNSET:
+                if self._media_rendered is MISSING:
                     if not self.content_type:
                         self.content_type = self.options.default_media_type
 
@@ -290,11 +223,11 @@ class Response(response.Response):
                 data = text.encode()
             except AttributeError:
                 # NOTE(kgriffs): Assume it was a bytes object already
-                data = text
+                data = text  # type: ignore[assignment]
 
         return data
 
-    def schedule(self, callback):
+    def schedule(self, callback: Callable[[], Awaitable[None]]) -> None:
         """Schedule an async callback to run soon after sending the HTTP response.
 
         This method can be used to execute a background job after the response
@@ -341,14 +274,14 @@ class Response(response.Response):
             #   by tests running in a Cython environment, but we can't
             #   detect it with the coverage tool.
 
-        rc = (callback, True)
+        rc: Tuple[Callable[[], Awaitable[None]], Literal[True]] = (callback, True)
 
         if not self._registered_callbacks:
             self._registered_callbacks = [rc]
         else:
             self._registered_callbacks.append(rc)
 
-    def schedule_sync(self, callback):
+    def schedule_sync(self, callback: Callable[[], None]) -> None:
         """Schedule a synchronous callback to run soon after sending the HTTP response.
 
         This method can be used to execute a background job after the
@@ -387,7 +320,7 @@ class Response(response.Response):
                 callable. The callback will be called without arguments.
         """
 
-        rc = (callback, False)
+        rc: Tuple[Callable[[], None], Literal[False]] = (callback, False)
 
         if not self._registered_callbacks:
             self._registered_callbacks = [rc]
@@ -398,7 +331,9 @@ class Response(response.Response):
     # Helper methods
     # ------------------------------------------------------------------------
 
-    def _asgi_headers(self, media_type=None):
+    def _asgi_headers(
+        self, media_type: Optional[str] = None
+    ) -> List[Tuple[bytes, bytes]]:
         """Convert headers into the format expected by ASGI servers.
 
         Header names must be lowercased and both name and value must be
diff --git a/falcon/asgi/ws.py b/falcon/asgi/ws.py
index 4cd9a6a7..c0203199 100644
--- a/falcon/asgi/ws.py
+++ b/falcon/asgi/ws.py
@@ -4,6 +4,7 @@ import asyncio
 import collections
 from enum import auto
 from enum import Enum
+import re
 from typing import Any, Deque, Dict, Iterable, Mapping, Optional, Tuple, Union
 
 from falcon import errors
@@ -28,6 +29,9 @@ class _WebSocketState(Enum):
     CLOSED = auto()
 
 
+_CLIENT_DISCONNECTED_CAUSE = re.compile(r'received (\d\d\d\d)')
+
+
 class WebSocket:
     """Represents a single WebSocket connection with a client."""
 
@@ -47,6 +51,8 @@ class WebSocket:
         'subprotocols',
     )
 
+    _asgi_receive: AsgiReceive
+    _asgi_send: AsgiSend
     _state: _WebSocketState
     _close_code: Optional[int]
     subprotocols: Tuple[str, ...]
@@ -81,7 +87,12 @@ class WebSocket:
         #   event via one of their receive() calls, and there is no
         #   need for the added overhead.
         self._buffered_receiver = _BufferedReceiver(receive, max_receive_queue)
-        self._asgi_receive = self._buffered_receiver.receive
+        if max_receive_queue > 0:
+            self._asgi_receive = self._buffered_receiver.receive
+        else:
+            # NOTE(vytas): Pass through the receive callable bypassing the
+            #   buffered receiver in the case max_receive_queue is set to 0.
+            self._asgi_receive = receive
         self._asgi_send = send
 
         mh_text = media_handlers[WebSocketPayloadType.TEXT]
@@ -468,6 +479,8 @@ class WebSocket:
         if self._buffered_receiver.client_disconnected:
             self._state = _WebSocketState.CLOSED
             self._close_code = self._buffered_receiver.client_disconnected_code
+
+        if self._state == _WebSocketState.CLOSED:
             raise errors.WebSocketDisconnected(self._close_code)
 
         try:
@@ -483,7 +496,16 @@ class WebSocket:
 
             translated_ex = self._translate_webserver_error(ex)
             if translated_ex:
-                raise translated_ex
+                # NOTE(vytas): Mark WebSocket as closed if we catch an error
+                #   upon sending. This is useful when not using the buffered
+                #   receiver, and not receiving anything at the given moment.
+                self._state = _WebSocketState.CLOSED
+                if isinstance(translated_ex, errors.WebSocketDisconnected):
+                    self._close_code = translated_ex.code
+
+                # NOTE(vytas): Use the raise from form in order to preserve
+                #   the traceback.
+                raise translated_ex from ex
 
             # NOTE(kgriffs): Re-raise other errors directly so that we don't
             #   obscure the traceback.
@@ -529,6 +551,25 @@ class WebSocket:
                 'WebSocket subprotocol must be from the list sent by the client'
             )
 
+        # NOTE(vytas): Per ASGI HTTP & WebSocket spec v2.4:
+        #   If send() is called on a closed connection the server should raise
+        #   a server-specific subclass of IOError.
+        # NOTE(vytas): Uvicorn 0.30.6 seems to conform to the spec only when
+        #   using the wsproto stack, it then raises an instance of
+        #   uvicorn.protocols.utils.ClientDisconnected.
+        if isinstance(ex, OSError):
+            close_code = None
+
+            # NOTE(vytas): If using the "websockets" backend, Uvicorn raises
+            #   and instance of OSError from a websockets exception like this:
+            #   "received 1001 (going away); then sent 1001 (going away)"
+            if ex.__cause__:
+                match = _CLIENT_DISCONNECTED_CAUSE.match(str(ex.__cause__))
+                if match:
+                    close_code = int(match.group(1))
+
+            return errors.WebSocketDisconnected(close_code)
+
         return None
 
 
@@ -679,7 +720,8 @@ class _BufferedReceiver:
         self.client_disconnected_code = None
 
     def start(self) -> None:
-        if self._pump_task is None:
+        # NOTE(vytas): Do not start anything if buffering is disabled.
+        if self._pump_task is None and self._max_queue > 0:
             self._pump_task = asyncio.create_task(self._pump())
 
     async def stop(self) -> None:
diff --git a/falcon/cmd/inspect_app.py b/falcon/cmd/inspect_app.py
index d1a1a038..d48c0d30 100644
--- a/falcon/cmd/inspect_app.py
+++ b/falcon/cmd/inspect_app.py
@@ -84,12 +84,13 @@ def load_app(parser, args):
     return app
 
 
+# TODO(vytas): Remove this placeholder altogether in Falcon 5.0.
 def route_main():
-    print(
-        'The "falcon-print-routes" command is deprecated. '
-        'Please use "falcon-inspect-app"'
+    sys.stderr.write(
+        'The "falcon-print-routes" command is no longer supported. \n\n'
+        'Please use "falcon-inspect-app" instead.\n\n'
     )
-    main()
+    sys.exit(2)
 
 
 def main():
diff --git a/falcon/constants.py b/falcon/constants.py
index dbbb9493..b1df391d 100644
--- a/falcon/constants.py
+++ b/falcon/constants.py
@@ -183,10 +183,6 @@ _DEFAULT_STATIC_MEDIA_TYPES = tuple(
     )
 )
 
-# NOTE(kgriffs): Special singleton to be used internally whenever using
-#   None would be ambiguous.
-_UNSET = object()  # TODO: remove once replaced with missing
-
 
 class WebSocketPayloadType(Enum):
     """Enum representing the two possible WebSocket payload types."""
diff --git a/falcon/errors.py b/falcon/errors.py
index afc1faa8..af64cf0f 100644
--- a/falcon/errors.py
+++ b/falcon/errors.py
@@ -41,7 +41,6 @@ from typing import Iterable, Optional, TYPE_CHECKING, Union
 
 from falcon.http_error import HTTPError
 import falcon.status_codes as status
-from falcon.util.deprecation import deprecated_args
 from falcon.util.misc import dt_to_http
 
 if TYPE_CHECKING:
@@ -176,6 +175,40 @@ class WebSocketServerError(WebSocketDisconnected):
 
 
 HTTPErrorKeywordArguments = Union[str, int, None]
+
+# TODO(vytas): Passing **kwargs down to HTTPError results in arg-type error in
+#   Mypy, because it is impossible to verify that, e.g., an int value was not
+#   erroneously passed to href instead of code, etc.
+#
+#   It is hard to properly type this on older Pythons, so we just sprinkle type
+#   ignores on the super().__init__(...) calls below. In any case, this call is
+#   internal to the framework.
+#
+#   On Python 3.11+, I have verified it is possible to properly type this
+#   pattern using typing.Unpack:
+#
+#   class HTTPErrorKeywordArguments(TypedDict):
+#       href: Optional[str]
+#       href_text: Optional[str]
+#       code: Optional[int]
+#
+#   class HTTPErrorSubclass(HTTPError):
+#       def __init__(
+#           self,
+#           *,
+#           title: Optional[str] = None,
+#           description: Optional[str] = None,
+#           headers: Optional[HeaderList] = None,
+#           **kwargs: Unpack[HTTPErrorKeywordArguments],
+#       ) -> None:
+#           super().__init__(
+#               status.HTTP_400,
+#               title=title,
+#               description=description,
+#               headers=headers,
+#               **kwargs,
+#           )
+
 RetryAfter = Union[int, datetime, None]
 
 
@@ -189,10 +222,7 @@ class HTTPBadRequest(HTTPError):
 
     (See also: RFC 7231, Section 6.5.1)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '400 Bad Request').
@@ -224,9 +254,9 @@ class HTTPBadRequest(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -237,7 +267,7 @@ class HTTPBadRequest(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -262,10 +292,7 @@ class HTTPUnauthorized(HTTPError):
 
     (See also: RFC 7235, Section 3.1)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '401 Unauthorized').
@@ -307,9 +334,9 @@ class HTTPUnauthorized(HTTPError):
 
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -325,7 +352,7 @@ class HTTPUnauthorized(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -350,10 +377,7 @@ class HTTPForbidden(HTTPError):
 
     (See also: RFC 7231, Section 6.5.4)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '403 Forbidden').
@@ -385,9 +409,9 @@ class HTTPForbidden(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -398,7 +422,7 @@ class HTTPForbidden(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -419,10 +443,7 @@ class HTTPNotFound(HTTPError):
 
     (See also: RFC 7231, Section 6.5.3)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Human-friendly error title. If not provided, and
@@ -456,9 +477,9 @@ class HTTPNotFound(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -469,7 +490,7 @@ class HTTPNotFound(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -483,10 +504,7 @@ class HTTPRouteNotFound(HTTPNotFound):
     behavior can be customized by registering a custom error handler for
     :class:`~.HTTPRouteNotFound`.
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Human-friendly error title. If not provided, and
@@ -536,11 +554,8 @@ class HTTPMethodNotAllowed(HTTPError):
 
     (See also: RFC 7231, Section 6.5.5)
 
-    Note:
-        ``allowed_methods`` is the only positional argument allowed, the other
-        arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    `allowed_methods` is the only positional argument allowed,
+    the other arguments are defined as keyword-only.
 
     Args:
         allowed_methods (list of str): Allowed HTTP methods for this
@@ -582,10 +597,10 @@ class HTTPMethodNotAllowed(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=1)
     def __init__(
         self,
         allowed_methods: Iterable[str],
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -598,7 +613,7 @@ class HTTPMethodNotAllowed(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -620,10 +635,7 @@ class HTTPNotAcceptable(HTTPError):
 
     (See also: RFC 7231, Section 6.5.6)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         description (str): Human-friendly description of the error, along with
@@ -654,9 +666,9 @@ class HTTPNotAcceptable(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -667,7 +679,7 @@ class HTTPNotAcceptable(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -692,10 +704,7 @@ class HTTPConflict(HTTPError):
 
     (See also: RFC 7231, Section 6.5.8)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '409 Conflict').
@@ -727,9 +736,9 @@ class HTTPConflict(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -740,7 +749,7 @@ class HTTPConflict(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -769,10 +778,7 @@ class HTTPGone(HTTPError):
 
     (See also: RFC 7231, Section 6.5.9)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Human-friendly error title. If not provided, and
@@ -806,9 +812,9 @@ class HTTPGone(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -819,7 +825,7 @@ class HTTPGone(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -835,10 +841,7 @@ class HTTPLengthRequired(HTTPError):
 
     (See also: RFC 7231, Section 6.5.10)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '411 Length Required').
@@ -870,9 +873,9 @@ class HTTPLengthRequired(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -883,7 +886,7 @@ class HTTPLengthRequired(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -900,10 +903,7 @@ class HTTPPreconditionFailed(HTTPError):
 
     (See also: RFC 7232, Section 4.2)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '412 Precondition Failed').
@@ -935,9 +935,9 @@ class HTTPPreconditionFailed(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -948,7 +948,7 @@ class HTTPPreconditionFailed(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -967,10 +967,7 @@ class HTTPPayloadTooLarge(HTTPError):
 
     (See also: RFC 7231, Section 6.5.11)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '413 Payload Too Large').
@@ -1010,9 +1007,9 @@ class HTTPPayloadTooLarge(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         retry_after: RetryAfter = None,
@@ -1024,7 +1021,7 @@ class HTTPPayloadTooLarge(HTTPError):
             title=title,
             description=description,
             headers=_parse_retry_after(headers, retry_after),
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -1046,10 +1043,7 @@ class HTTPUriTooLong(HTTPError):
 
     (See also: RFC 7231, Section 6.5.12)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '414 URI Too Long').
@@ -1081,9 +1075,9 @@ class HTTPUriTooLong(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -1094,7 +1088,7 @@ class HTTPUriTooLong(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -1111,10 +1105,7 @@ class HTTPUnsupportedMediaType(HTTPError):
 
     (See also: RFC 7231, Section 6.5.13)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '415 Unsupported Media Type').
@@ -1146,9 +1137,9 @@ class HTTPUnsupportedMediaType(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -1159,7 +1150,7 @@ class HTTPUnsupportedMediaType(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -1180,11 +1171,8 @@ class HTTPRangeNotSatisfiable(HTTPError):
 
     (See also: RFC 7233, Section 4.4)
 
-    Note:
-        ``resource_length`` is the only positional argument allowed, the other
-        arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    `resource_length` is the only positional argument allowed,
+    the other arguments are defined as keyword-only.
 
     Args:
         resource_length: The maximum value for the last-byte-pos of a range
@@ -1224,10 +1212,10 @@ class HTTPRangeNotSatisfiable(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=1)
     def __init__(
         self,
         resource_length: int,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -1241,7 +1229,7 @@ class HTTPRangeNotSatisfiable(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -1260,10 +1248,7 @@ class HTTPUnprocessableEntity(HTTPError):
 
     (See also: RFC 4918, Section 11.2)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '422 Unprocessable Entity').
@@ -1295,9 +1280,9 @@ class HTTPUnprocessableEntity(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -1308,7 +1293,7 @@ class HTTPUnprocessableEntity(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -1322,10 +1307,7 @@ class HTTPLocked(HTTPError):
 
     (See also: RFC 4918, Section 11.3)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '423 Locked').
@@ -1357,9 +1339,9 @@ class HTTPLocked(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -1370,7 +1352,7 @@ class HTTPLocked(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -1383,10 +1365,7 @@ class HTTPFailedDependency(HTTPError):
 
     (See also: RFC 4918, Section 11.4)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '424 Failed Dependency').
@@ -1418,9 +1397,9 @@ class HTTPFailedDependency(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -1431,7 +1410,7 @@ class HTTPFailedDependency(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -1452,10 +1431,7 @@ class HTTPPreconditionRequired(HTTPError):
 
     (See also: RFC 6585, Section 3)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '428 Precondition Required').
@@ -1487,9 +1463,9 @@ class HTTPPreconditionRequired(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -1500,7 +1476,7 @@ class HTTPPreconditionRequired(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -1518,10 +1494,7 @@ class HTTPTooManyRequests(HTTPError):
 
     (See also: RFC 6585, Section 4)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '429 Too Many Requests').
@@ -1561,9 +1534,9 @@ class HTTPTooManyRequests(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -1575,7 +1548,7 @@ class HTTPTooManyRequests(HTTPError):
             title=title,
             description=description,
             headers=_parse_retry_after(headers, retry_after),
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -1595,10 +1568,7 @@ class HTTPRequestHeaderFieldsTooLarge(HTTPError):
 
     (See also: RFC 6585, Section 5)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '431 Request Header Fields Too Large').
@@ -1630,9 +1600,9 @@ class HTTPRequestHeaderFieldsTooLarge(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -1643,7 +1613,7 @@ class HTTPRequestHeaderFieldsTooLarge(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -1670,10 +1640,7 @@ class HTTPUnavailableForLegalReasons(HTTPError):
 
     (See also: RFC 7725, Section 3)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '451 Unavailable For Legal Reasons').
@@ -1705,9 +1672,9 @@ class HTTPUnavailableForLegalReasons(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -1718,7 +1685,7 @@ class HTTPUnavailableForLegalReasons(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -1730,10 +1697,7 @@ class HTTPInternalServerError(HTTPError):
 
     (See also: RFC 7231, Section 6.6.1)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '500 Internal Server Error').
@@ -1766,9 +1730,9 @@ class HTTPInternalServerError(HTTPError):
 
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -1779,7 +1743,7 @@ class HTTPInternalServerError(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -1797,10 +1761,7 @@ class HTTPNotImplemented(HTTPError):
 
     (See also: RFC 7231, Section 6.6.2)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '500 Internal Server Error').
@@ -1834,9 +1795,9 @@ class HTTPNotImplemented(HTTPError):
 
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -1847,7 +1808,7 @@ class HTTPNotImplemented(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -1860,10 +1821,7 @@ class HTTPBadGateway(HTTPError):
 
     (See also: RFC 7231, Section 6.6.3)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '502 Bad Gateway').
@@ -1895,9 +1853,9 @@ class HTTPBadGateway(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -1908,7 +1866,7 @@ class HTTPBadGateway(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -1929,10 +1887,7 @@ class HTTPServiceUnavailable(HTTPError):
 
     (See also: RFC 7231, Section 6.6.4)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '503 Service Unavailable').
@@ -1972,9 +1927,9 @@ class HTTPServiceUnavailable(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -1986,7 +1941,7 @@ class HTTPServiceUnavailable(HTTPError):
             title=title,
             description=description,
             headers=_parse_retry_after(headers, retry_after),
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -2000,10 +1955,7 @@ class HTTPGatewayTimeout(HTTPError):
 
     (See also: RFC 7231, Section 6.6.5)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '503 Service Unavailable').
@@ -2035,9 +1987,9 @@ class HTTPGatewayTimeout(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -2048,7 +2000,7 @@ class HTTPGatewayTimeout(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -2067,10 +2019,7 @@ class HTTPVersionNotSupported(HTTPError):
 
     (See also: RFC 7231, Section 6.6.6)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '503 Service Unavailable').
@@ -2102,9 +2051,9 @@ class HTTPVersionNotSupported(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -2115,7 +2064,7 @@ class HTTPVersionNotSupported(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -2132,10 +2081,7 @@ class HTTPInsufficientStorage(HTTPError):
 
     (See also: RFC 4918, Section 11.5)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '507 Insufficient Storage').
@@ -2167,9 +2113,9 @@ class HTTPInsufficientStorage(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -2180,7 +2126,7 @@ class HTTPInsufficientStorage(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -2194,10 +2140,7 @@ class HTTPLoopDetected(HTTPError):
 
     (See also: RFC 5842, Section 7.2)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '508 Loop Detected').
@@ -2229,9 +2172,9 @@ class HTTPLoopDetected(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -2242,7 +2185,7 @@ class HTTPLoopDetected(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -2268,10 +2211,7 @@ class HTTPNetworkAuthenticationRequired(HTTPError):
 
     (See also: RFC 6585, Section 6)
 
-    Note:
-        All the arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    All the arguments are defined as keyword-only.
 
     Keyword Args:
         title (str): Error title (default '511 Network Authentication Required').
@@ -2303,9 +2243,9 @@ class HTTPNetworkAuthenticationRequired(HTTPError):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
@@ -2316,7 +2256,7 @@ class HTTPNetworkAuthenticationRequired(HTTPError):
             title=title,
             description=description,
             headers=headers,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -2325,11 +2265,8 @@ class HTTPInvalidHeader(HTTPBadRequest):
 
     One of the headers in the request is invalid.
 
-    Note:
-        ``msg`` and ``header_name`` are the only positional argument allowed,
-        the other arguments should be passed as keyword only. Using them as
-        positional arguments will raise a deprecation warning and will result
-        in an error in a future version of falcon.
+    `msg` and `header_name` are the only positional arguments allowed,
+    the other arguments are defined as keyword-only.
 
     Args:
         msg (str): A description of why the value is invalid.
@@ -2362,11 +2299,11 @@ class HTTPInvalidHeader(HTTPBadRequest):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=2)
     def __init__(
         self,
         msg: str,
         header_name: str,
+        *,
         headers: Optional[HeaderList] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
@@ -2386,11 +2323,8 @@ class HTTPMissingHeader(HTTPBadRequest):
 
     A header is missing from the request.
 
-    Note:
-        ``header_name`` is the only positional argument allowed, the other
-        arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    `header_name` is the only positional argument allowed,
+    the other arguments are defined as keyword-only.
 
     Args:
         header_name (str): The name of the missing header.
@@ -2422,10 +2356,10 @@ class HTTPMissingHeader(HTTPBadRequest):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=1)
     def __init__(
         self,
         header_name: str,
+        *,
         headers: Optional[HeaderList] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
@@ -2447,11 +2381,8 @@ class HTTPInvalidParam(HTTPBadRequest):
     parameter in a query string, form, or document that was submitted
     with the request.
 
-    Note:
-        ``msg`` and ``param_name`` are the only positional argument allowed,
-        the other arguments should be passed as keyword only. Using them as
-        positional arguments will raise a deprecation warning and will result
-        in an error in a future version of falcon.
+    `msg` and `param_name` are the only positional arguments allowed,
+    the other arguments are defined as keyword-only.
 
     Args:
         msg (str): A description of the invalid parameter.
@@ -2484,11 +2415,11 @@ class HTTPInvalidParam(HTTPBadRequest):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=2)
     def __init__(
         self,
         msg: str,
         param_name: str,
+        *,
         headers: Optional[HeaderList] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ) -> None:
@@ -2510,11 +2441,8 @@ class HTTPMissingParam(HTTPBadRequest):
     parameter in a query string, form, or document that was submitted
     with the request.
 
-    Note:
-        ``param_name`` is the only positional argument allowed, the other
-        arguments should be passed as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    `param_name` is the only positional argument allowed,
+    the other arguments are defined as keyword-only.
 
     Args:
         param_name (str): The name of the missing parameter.
@@ -2546,10 +2474,10 @@ class HTTPMissingParam(HTTPBadRequest):
             base articles related to this error (default ``None``).
     """
 
-    @deprecated_args(allowed_positional=1)
     def __init__(
         self,
         param_name: str,
+        *,
         headers: Optional[HeaderList] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ) -> None:
@@ -2607,7 +2535,7 @@ class MediaNotFoundError(HTTPBadRequest):
         super().__init__(
             title='Invalid {0}'.format(media_type),
             description='Could not parse an empty {0} body'.format(media_type),
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
@@ -2652,7 +2580,9 @@ class MediaMalformedError(HTTPBadRequest):
         self, media_type: str, **kwargs: Union[HeaderList, HTTPErrorKeywordArguments]
     ):
         super().__init__(
-            title='Invalid {0}'.format(media_type), description=None, **kwargs
+            title='Invalid {0}'.format(media_type),
+            description=None,
+            **kwargs,  # type: ignore[arg-type]
         )
         self._media_type = media_type
 
@@ -2749,9 +2679,9 @@ class MultipartParseError(MediaMalformedError):
     # NOTE(caselit): remove the description @property in MediaMalformedError
     description = None
 
-    @deprecated_args(allowed_positional=0)
     def __init__(
         self,
+        *,
         description: Optional[str] = None,
         **kwargs: Union[HeaderList, HTTPErrorKeywordArguments],
     ) -> None:
@@ -2759,7 +2689,7 @@ class MultipartParseError(MediaMalformedError):
             self,
             title='Malformed multipart/form-data request media',
             description=description,
-            **kwargs,
+            **kwargs,  # type: ignore[arg-type]
         )
 
 
diff --git a/falcon/http_error.py b/falcon/http_error.py
index 3f4af635..51e83715 100644
--- a/falcon/http_error.py
+++ b/falcon/http_error.py
@@ -23,7 +23,6 @@ from falcon.constants import MEDIA_JSON
 from falcon.util import code_to_http_status
 from falcon.util import http_status_to_code
 from falcon.util import uri
-from falcon.util.deprecation import deprecated_args
 
 if TYPE_CHECKING:
     from falcon.media import BaseHandler
@@ -49,11 +48,8 @@ class HTTPError(Exception):
     is implemented via ``to_dict()``). To also support XML, override
     the ``to_xml()`` method.
 
-    Note:
-        ``status`` is the only positional argument allowed, the other
-        arguments should be used as keyword only. Using them as positional
-        arguments will raise a deprecation warning and will result in an
-        error in a future version of falcon.
+    `status` is the only positional argument allowed,
+    the other arguments are defined as keyword-only.
 
     Args:
         status (Union[str,int]): HTTP status code or line (e.g.,
@@ -117,10 +113,10 @@ class HTTPError(Exception):
         'code',
     )
 
-    @deprecated_args(allowed_positional=1)
     def __init__(
         self,
         status: ResponseStatus,
+        *,
         title: Optional[str] = None,
         description: Optional[str] = None,
         headers: Optional[HeaderList] = None,
diff --git a/falcon/http_status.py b/falcon/http_status.py
index 1a591fcf..dd552277 100644
--- a/falcon/http_status.py
+++ b/falcon/http_status.py
@@ -18,7 +18,6 @@ from __future__ import annotations
 from typing import Optional, TYPE_CHECKING
 
 from falcon.util import http_status_to_code
-from falcon.util.deprecation import AttributeRemovedError
 
 if TYPE_CHECKING:
     from falcon.typing import HeaderList
@@ -69,10 +68,3 @@ class HTTPStatus(Exception):
     def status_code(self) -> int:
         """HTTP status code normalized from :attr:`status`."""
         return http_status_to_code(self.status)
-
-    @property
-    def body(self) -> None:
-        raise AttributeRemovedError(
-            'The body attribute is no longer supported. '
-            'Please use the text attribute instead.'
-        )
diff --git a/falcon/media/handlers.py b/falcon/media/handlers.py
index 4eeaf4f7..8c1965f0 100644
--- a/falcon/media/handlers.py
+++ b/falcon/media/handlers.py
@@ -30,7 +30,6 @@ from falcon.media.multipart import MultipartParseOptions
 from falcon.media.urlencoded import URLEncodedFormHandler
 from falcon.typing import DeserializeSync
 from falcon.typing import SerializeSync
-from falcon.util import deprecation
 from falcon.util import misc
 from falcon.vendor import mimeparse
 
@@ -180,35 +179,6 @@ class Handlers(UserDict):
         handlers_cls = type(self)
         return handlers_cls(self.data)
 
-    @deprecation.deprecated(
-        'This undocumented method is no longer supported as part of the public '
-        'interface and will be removed in a future release.'
-    )
-    def find_by_media_type(
-        self, media_type: Optional[str], default: str, raise_not_found: bool = True
-    ) -> Optional[BaseHandler]:
-        # PERF(jmvrbanac): Check via a quick methods first for performance
-        if media_type == '*/*' or not media_type:
-            media_type = default
-
-        try:
-            return self.data[media_type]
-        except KeyError:
-            pass
-
-        # PERF(jmvrbanac): Fallback to the slower method.
-        # NOTE(kgriffs): Wrap keys in a tuple to make them hashable.
-        resolved = _best_match(media_type, tuple(self.data.keys()))
-
-        if not resolved:
-            if raise_not_found:
-                raise errors.HTTPUnsupportedMediaType(
-                    description='{0} is an unsupported media type.'.format(media_type)
-                )
-            return None
-
-        return self.data[resolved]
-
 
 def _best_match(media_type: str, all_media_types: Sequence[str]) -> Optional[str]:
     result = None
diff --git a/falcon/media/urlencoded.py b/falcon/media/urlencoded.py
index 1d7f6cb0..ee38391d 100644
--- a/falcon/media/urlencoded.py
+++ b/falcon/media/urlencoded.py
@@ -52,7 +52,9 @@ class URLEncodedFormHandler(BaseHandler):
 
     def _deserialize(self, body: bytes) -> Any:
         try:
-            # NOTE(kgriffs): According to http://goo.gl/6rlcux the
+            # NOTE(kgriffs): According to
+            # https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#application%2Fx-www-form-urlencoded-encoding-algorithm
+            # the
             # body should be US-ASCII. Enforcing this also helps
             # catch malicious input.
             body_str = body.decode('ascii')
diff --git a/falcon/media/validators/jsonschema.py b/falcon/media/validators/jsonschema.py
index 8fc53ade..0e6f14b6 100644
--- a/falcon/media/validators/jsonschema.py
+++ b/falcon/media/validators/jsonschema.py
@@ -56,9 +56,9 @@ def validate(req_schema=None, resp_schema=None, is_async=False):
 
     Example:
 
-        .. tabs::
+        .. tab-set::
 
-            .. tab:: WSGI
+            .. tab-item:: WSGI
 
                 .. code:: python
 
@@ -71,7 +71,7 @@ def validate(req_schema=None, resp_schema=None, is_async=False):
 
                     # -- snip --
 
-            .. tab:: ASGI
+            .. tab-item:: ASGI
 
                 .. code:: python
 
@@ -84,7 +84,7 @@ def validate(req_schema=None, resp_schema=None, is_async=False):
 
                     # -- snip --
 
-            .. tab:: ASGI (Cythonized App)
+            .. tab-item:: ASGI (Cythonized App)
 
                 .. code:: python
 
diff --git a/falcon/request.py b/falcon/request.py
index db89c947..6c4fd8b4 100644
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -35,6 +35,7 @@ from typing import (
     Union,
 )
 from uuid import UUID
+import warnings
 
 from falcon import errors
 from falcon import request_helpers as helpers
@@ -50,7 +51,7 @@ from falcon.typing import MISSING
 from falcon.typing import MissingOr
 from falcon.typing import ReadableIO
 from falcon.typing import StoreArgument
-from falcon.util import deprecated
+from falcon.util import deprecation
 from falcon.util import ETag
 from falcon.util import structures
 from falcon.util.uri import parse_host
@@ -81,7 +82,7 @@ class Request:
             also PEP-3333.
 
     Keyword Arguments:
-        options (dict): Set of global options passed from the App handler.
+        options (RequestOptions): Set of global options passed from the App handler.
     """
 
     __slots__ = (
@@ -323,7 +324,7 @@ class Request:
         # cycles and parse the content type for real, but
         # this heuristic will work virtually all the time.
         if (
-            self.options.auto_parse_form_urlencoded
+            self.options._auto_parse_form_urlencoded
             and self.content_type is not None
             and 'application/x-www-form-urlencoded' in self.content_type
             and
@@ -634,8 +635,12 @@ class Request:
             return ''
 
     @property
-    # NOTE(caselit): Deprecated long ago. Warns since 4.0
-    @deprecated('Use `root_path` instead', is_property=True)
+    # NOTE(caselit): Deprecated long ago. Warns since 4.0.
+    @deprecation.deprecated(
+        'Use `root_path` instead. '
+        '(This compatibility alias will be removed in Falcon 5.0.)',
+        is_property=True,
+    )
     def app(self) -> str:
         """Deprecated alias for :attr:`root_path`."""
         return self.root_path
@@ -2368,7 +2373,9 @@ class Request:
 
         body_bytes = self.stream.read(content_length)
 
-        # NOTE(kgriffs): According to http://goo.gl/6rlcux the
+        # NOTE(kgriffs): According to
+        # https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#application%2Fx-www-form-urlencoded-encoding-algorithm
+        # the
         # body should be US-ASCII. Enforcing this also helps
         # catch malicious input.
         try:
@@ -2409,37 +2416,58 @@ class RequestOptions:
     For comma-separated values, this option also determines whether or not
     empty elements in the parsed list are retained.
     """
-    auto_parse_form_urlencoded: bool
-    """Set to ``True`` in order to automatically consume the request stream and merge
-    the results into the request's query string params when the request's content
-    type is ``application/x-www-form-urlencoded``` (default ``False``).
 
-    Enabling this option for WSGI apps makes the form parameters accessible via
-    :attr:`~falcon.Request.params`, :meth:`~falcon.Request.get_param`, etc.
+    @property
+    def auto_parse_form_urlencoded(self) -> bool:
+        """Set to ``True`` in order to automatically consume the request stream
+        and merge the results into the request's query string params when the
+        request's content type is ``application/x-www-form-urlencoded```
+        (default ``False``).
+
+        Enabling this option for WSGI apps makes the form parameters accessible
+        via :attr:`~falcon.Request.params`, :meth:`~falcon.Request.get_param`,
+        etc.
 
-    Warning:
-        The `auto_parse_form_urlencoded` option is not supported for
-        ASGI apps, and is considered deprecated for WSGI apps as of
-        Falcon 3.0, in favor of accessing URL-encoded forms
-        through :attr:`~Request.media`.
+        Warning:
+            The `auto_parse_form_urlencoded` option is not supported for
+            ASGI apps, and is considered deprecated for WSGI apps as of
+            Falcon 3.0, in favor of accessing URL-encoded forms
+            through :attr:`~Request.media`.
 
-        See also: :ref:`access_urlencoded_form`
+            The attribute and the auto-parsing functionality will be removed
+            entirely in Falcon 5.0.
 
-    Warning:
-        When this option is enabled, the request's body
-        stream will be left at EOF. The original data is
-        not retained by the framework.
+            See also: :ref:`access_urlencoded_form`.
+
+        Warning:
+            When this option is enabled, the request's body
+            stream will be left at EOF. The original data is
+            not retained by the framework.
+
+        Note:
+            The character encoding for fields, before
+            percent-encoding non-ASCII bytes, is assumed to be
+            UTF-8. The special `_charset_` field is ignored if
+            present.
+
+            Falcon expects form-encoded request bodies to be
+            encoded according to the standard W3C algorithm (see
+            also https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#application%2Fx-www-form-urlencoded-encoding-algorithm).
+        """  # noqa: D205
+        return self._auto_parse_form_urlencoded
+
+    @auto_parse_form_urlencoded.setter
+    def auto_parse_form_urlencoded(self, value: bool) -> None:
+        if value:
+            warnings.warn(
+                'The RequestOptions.auto_parse_form_urlencoded option is '
+                'deprecated. Please use Request.get_media() to consume '
+                'the submitted URL-encoded form instead.',
+                category=deprecation.DeprecatedWarning,
+            )
+
+        self._auto_parse_form_urlencoded = value
 
-    Note:
-        The character encoding for fields, before
-        percent-encoding non-ASCII bytes, is assumed to be
-        UTF-8. The special `_charset_` field is ignored if
-        present.
-
-        Falcon expects form-encoded request bodies to be
-        encoded according to the standard W3C algorithm (see
-        also https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#application%2Fx-www-form-urlencoded-encoding-algorithm).
-    """
     auto_parse_qs_csv: bool
     """Set to ``True`` to split query string values on any non-percent-encoded
     commas (default ``False``).
@@ -2485,7 +2513,7 @@ class RequestOptions:
 
     __slots__ = (
         'keep_blank_qs_values',
-        'auto_parse_form_urlencoded',
+        '_auto_parse_form_urlencoded',
         'auto_parse_qs_csv',
         'strip_url_path_trailing_slash',
         'default_media_type',
@@ -2494,7 +2522,7 @@ class RequestOptions:
 
     def __init__(self) -> None:
         self.keep_blank_qs_values = True
-        self.auto_parse_form_urlencoded = False
+        self._auto_parse_form_urlencoded = False
         self.auto_parse_qs_csv = False
         self.strip_url_path_trailing_slash = False
         self.default_media_type = DEFAULT_MEDIA_TYPE
diff --git a/falcon/responders.py b/falcon/responders.py
index b3ee7376..b28277b4 100644
--- a/falcon/responders.py
+++ b/falcon/responders.py
@@ -14,33 +14,47 @@
 
 """Default responder implementations."""
 
+from __future__ import annotations
+
+from typing import Any, Iterable, NoReturn, TYPE_CHECKING, Union
+
 from falcon.errors import HTTPBadRequest
 from falcon.errors import HTTPMethodNotAllowed
 from falcon.errors import HTTPRouteNotFound
 from falcon.status_codes import HTTP_200
+from falcon.typing import AsgiResponderCallable
+from falcon.typing import ResponderCallable
+
+if TYPE_CHECKING:
+    from falcon import Request
+    from falcon import Response
+    from falcon.asgi import Request as AsgiRequest
+    from falcon.asgi import Response as AsgiResponse
 
 
-def path_not_found(req, resp, **kwargs):
+def path_not_found(req: Request, resp: Response, **kwargs: Any) -> NoReturn:
     """Raise 404 HTTPRouteNotFound error."""
     raise HTTPRouteNotFound()
 
 
-async def path_not_found_async(req, resp, **kwargs):
+async def path_not_found_async(req: Request, resp: Response, **kwargs: Any) -> NoReturn:
     """Raise 404 HTTPRouteNotFound error."""
     raise HTTPRouteNotFound()
 
 
-def bad_request(req, resp, **kwargs):
+def bad_request(req: Request, resp: Response, **kwargs: Any) -> NoReturn:
     """Raise 400 HTTPBadRequest error."""
     raise HTTPBadRequest(title='Bad request', description='Invalid HTTP method')
 
 
-async def bad_request_async(req, resp, **kwargs):
+async def bad_request_async(req: Request, resp: Response, **kwargs: Any) -> NoReturn:
     """Raise 400 HTTPBadRequest error."""
     raise HTTPBadRequest(title='Bad request', description='Invalid HTTP method')
 
 
-def create_method_not_allowed(allowed_methods, asgi=False):
+def create_method_not_allowed(
+    allowed_methods: Iterable[str], asgi: bool = False
+) -> Union[ResponderCallable, AsgiResponderCallable]:
     """Create a responder for "405 Method Not Allowed".
 
     Args:
@@ -52,18 +66,22 @@ def create_method_not_allowed(allowed_methods, asgi=False):
 
     if asgi:
 
-        async def method_not_allowed_responder_async(req, resp, **kwargs):
+        async def method_not_allowed_responder_async(
+            req: AsgiRequest, resp: AsgiResponse, **kwargs: Any
+        ) -> NoReturn:
             raise HTTPMethodNotAllowed(allowed_methods)
 
         return method_not_allowed_responder_async
 
-    def method_not_allowed(req, resp, **kwargs):
+    def method_not_allowed(req: Request, resp: Response, **kwargs: Any) -> NoReturn:
         raise HTTPMethodNotAllowed(allowed_methods)
 
     return method_not_allowed
 
 
-def create_default_options(allowed_methods, asgi=False):
+def create_default_options(
+    allowed_methods: Iterable[str], asgi: bool = False
+) -> Union[ResponderCallable, AsgiResponderCallable]:
     """Create a default responder for the OPTIONS method.
 
     Args:
@@ -76,14 +94,16 @@ def create_default_options(allowed_methods, asgi=False):
 
     if asgi:
 
-        async def options_responder_async(req, resp, **kwargs):
+        async def options_responder_async(
+            req: AsgiRequest, resp: AsgiResponse, **kwargs: Any
+        ) -> None:
             resp.status = HTTP_200
             resp.set_header('Allow', allowed)
             resp.set_header('Content-Length', '0')
 
         return options_responder_async
 
-    def options_responder(req, resp, **kwargs):
+    def options_responder(req: Request, resp: Response, **kwargs: Any) -> None:
         resp.status = HTTP_200
         resp.set_header('Allow', allowed)
         resp.set_header('Content-Length', '0')
diff --git a/falcon/response.py b/falcon/response.py
index 9446e662..a8575970 100644
--- a/falcon/response.py
+++ b/falcon/response.py
@@ -16,13 +16,27 @@
 
 from __future__ import annotations
 
+from datetime import datetime
 from datetime import timezone
 import functools
 import mimetypes
-from typing import Dict
+from typing import (
+    Any,
+    ClassVar,
+    Dict,
+    Iterable,
+    List,
+    Mapping,
+    NoReturn,
+    Optional,
+    overload,
+    Tuple,
+    Type,
+    TYPE_CHECKING,
+    Union,
+)
 
 from falcon.constants import _DEFAULT_STATIC_MEDIA_TYPES
-from falcon.constants import _UNSET
 from falcon.constants import DEFAULT_MEDIA_TYPE
 from falcon.errors import HeaderNotSupported
 from falcon.media import Handlers
@@ -32,17 +46,24 @@ from falcon.response_helpers import format_header_value_list
 from falcon.response_helpers import format_range
 from falcon.response_helpers import header_property
 from falcon.response_helpers import is_ascii_encodable
+from falcon.typing import Headers
+from falcon.typing import MISSING
+from falcon.typing import MissingOr
+from falcon.typing import RangeSetHeader
+from falcon.typing import ReadableIO
 from falcon.util import dt_to_http
 from falcon.util import http_cookies
 from falcon.util import http_status_to_code
 from falcon.util import structures
 from falcon.util.deprecation import AttributeRemovedError
-from falcon.util.deprecation import deprecated
 from falcon.util.uri import encode_check_escaped as uri_encode
 from falcon.util.uri import encode_value_check_escaped as uri_encode_value
 
-_RESERVED_CROSSORIGIN_VALUES = frozenset({'anonymous', 'use-credentials'})
+if TYPE_CHECKING:
+    import http
+
 
+_RESERVED_CROSSORIGIN_VALUES = frozenset({'anonymous', 'use-credentials'})
 _RESERVED_SAMESITE_VALUES = frozenset({'lax', 'strict', 'none'})
 
 
@@ -53,100 +74,7 @@ class Response:
         ``Response`` is not meant to be instantiated directly by responders.
 
     Keyword Arguments:
-        options (dict): Set of global options passed from the App handler.
-
-    Attributes:
-        status (Union[str,int]): HTTP status code or line (e.g., ``'200 OK'``).
-            This may be set to a member of :class:`http.HTTPStatus`, an HTTP
-            status line string or byte string (e.g., ``'200 OK'``), or an
-            ``int``.
-
-            Note:
-                The Falcon framework itself provides a number of constants for
-                common status codes. They all start with the ``HTTP_`` prefix,
-                as in: ``falcon.HTTP_204``. (See also: :ref:`status`.)
-
-        status_code (int): HTTP status code normalized from :attr:`status`.
-            When a code is assigned to this property, :attr:`status` is updated,
-            and vice-versa. The status code can be useful when needing to check
-            in middleware for codes that fall into a certain class, e.g.::
-
-                if resp.status_code >= 400:
-                    log.warning(f'returning error response: {resp.status_code}')
-
-        media (object): A serializable object supported by the media handlers
-            configured via :class:`falcon.RequestOptions`.
-
-            Note:
-                See also :ref:`media` for more information regarding media
-                handling.
-
-        text (str): String representing response content.
-
-            Note:
-                Falcon will encode the given text as UTF-8
-                in the response. If the content is already a byte string,
-                use the :attr:`data` attribute instead (it's faster).
-
-        data (bytes): Byte string representing response content.
-
-            Use this attribute in lieu of `text` when your content is
-            already a byte string (of type ``bytes``). See also the note below.
-
-            Warning:
-                Always use the `text` attribute for text, or encode it
-                first to ``bytes`` when using the `data` attribute, to
-                ensure Unicode characters are properly encoded in the
-                HTTP response.
-
-        stream: Either a file-like object with a `read()` method that takes
-            an optional size argument and returns a block of bytes, or an
-            iterable object, representing response content, and yielding
-            blocks as byte strings. Falcon will use *wsgi.file_wrapper*, if
-            provided by the WSGI server, in order to efficiently serve
-            file-like objects.
-
-            Note:
-                If the stream is set to an iterable object that requires
-                resource cleanup, it can implement a close() method to do so.
-                The close() method will be called upon completion of the request.
-
-        context (object): Empty object to hold any data (in its attributes)
-            about the response which is specific to your app (e.g. session
-            object). Falcon itself will not interact with this attribute after
-            it has been initialized.
-
-            Note:
-                **New in 2.0:** The default `context_type` (see below) was
-                changed from :class:`dict` to a bare class; the preferred way to
-                pass response-specific data is now to set attributes directly
-                on the `context` object. For example::
-
-                    resp.context.cache_strategy = 'lru'
-
-        context_type (class): Class variable that determines the factory or
-            type to use for initializing the `context` attribute. By default,
-            the framework will instantiate bare objects (instances of the bare
-            :class:`falcon.Context` class). However, you may override this
-            behavior by creating a custom child class of
-            :class:`falcon.Response`, and then passing that new class to
-            ``falcon.App()`` by way of the latter's `response_type` parameter.
-
-            Note:
-                When overriding `context_type` with a factory function (as
-                opposed to a class), the function is called like a method of
-                the current Response instance. Therefore the first argument is
-                the Response instance itself (self).
-
-        options (dict): Set of global options passed from the App handler.
-
-        headers (dict): Copy of all headers set for the response,
-            sans cookies. Note that a new copy is created and returned each
-            time this property is referenced.
-
-        complete (bool): Set to ``True`` from within a middleware method to
-            signal to the framework that request processing should be
-            short-circuited (see also :ref:`Middleware <middleware>`).
+        options (ResponseOptions): Set of global options passed from the App handler.
     """
 
     __slots__ = (
@@ -164,12 +92,81 @@ class Response:
         '__dict__',
     )
 
-    complete = False
+    _cookies: Optional[http_cookies.SimpleCookie]
+    _data: Optional[bytes]
+    _extra_headers: Optional[List[Tuple[str, str]]]
+    _headers: Headers
+    _media: Optional[Any]
+    _media_rendered: MissingOr[bytes]
 
     # Child classes may override this
-    context_type = structures.Context
+    context_type: ClassVar[Type[structures.Context]] = structures.Context
+    """Class variable that determines the factory or
+    type to use for initializing the `context` attribute. By default,
+    the framework will instantiate bare objects (instances of the bare
+    :class:`falcon.Context` class). However, you may override this
+    behavior by creating a custom child class of
+    :class:`falcon.Response`, and then passing that new class to
+    ``falcon.App()`` by way of the latter's `response_type` parameter.
+
+    Note:
+        When overriding `context_type` with a factory function (as
+        opposed to a class), the function is called like a method of
+        the current Response instance. Therefore the first argument is
+        the Response instance itself (self).
+    """
+
+    # Attribute declaration
+    complete: bool = False
+    """Set to ``True`` from within a middleware method to signal to the framework that
+    request processing should be short-circuited (see also
+    :ref:`Middleware <middleware>`).
+    """
+    status: Union[str, int, http.HTTPStatus]
+    """HTTP status code or line (e.g., ``'200 OK'``).
+
+    This may be set to a member of :class:`http.HTTPStatus`, an HTTP status line
+    string (e.g., ``'200 OK'``), or an ``int``.
+
+    Note:
+        The Falcon framework itself provides a number of constants for
+        common status codes. They all start with the ``HTTP_`` prefix,
+        as in: ``falcon.HTTP_204``. (See also: :ref:`status`.)
+    """
+    text: Optional[str]
+    """String representing response content.
 
-    def __init__(self, options=None):
+    Note:
+        Falcon will encode the given text as UTF-8 in the response. If the content
+        is already a byte string, use the :attr:`data` attribute instead (it's faster).
+    """
+    stream: Union[ReadableIO, Iterable[bytes], None]
+    """Either a file-like object with a `read()` method that takes an optional size
+    argument and returns a block of bytes, or an iterable object, representing response
+    content, and yielding blocks as byte strings. Falcon will use *wsgi.file_wrapper*,
+    if provided by the WSGI server, in order to efficiently serve file-like objects.
+
+    Note:
+        If the stream is set to an iterable object that requires
+        resource cleanup, it can implement a close() method to do so.
+        The close() method will be called upon completion of the request.
+    """
+    context: structures.Context
+    """Empty object to hold any data (in its attributes) about the response which is
+    specific to your app (e.g. session object).
+    Falcon itself will not interact with this attribute after it has been initialized.
+
+    Note:
+        The preferred way to pass response-specific data, when using the
+        default context type, is to set attributes directly on the
+        `context` object. For example::
+
+            resp.context.cache_strategy = 'lru'
+    """
+    options: ResponseOptions
+    """Set of global options passed in from the App handler."""
+
+    def __init__(self, options: Optional[ResponseOptions] = None) -> None:
         self.status = '200 OK'
         self._headers = {}
 
@@ -191,54 +188,72 @@ class Response:
         self.stream = None
         self._data = None
         self._media = None
-        self._media_rendered = _UNSET
+        self._media_rendered = MISSING
 
         self.context = self.context_type()
 
     @property
     def status_code(self) -> int:
+        """HTTP status code normalized from :attr:`status`.
+
+        When a code is assigned to this property, :attr:`status` is updated,
+        and vice-versa. The status code can be useful when needing to check
+        in middleware for codes that fall into a certain class, e.g.::
+
+            if resp.status_code >= 400:
+                log.warning(f'returning error response: {resp.status_code}')
+        """
         return http_status_to_code(self.status)
 
     @status_code.setter
-    def status_code(self, value):
+    def status_code(self, value: int) -> None:
         self.status = value
 
     @property
-    def body(self):
-        raise AttributeRemovedError(
-            'The body attribute is no longer supported. '
-            'Please use the text attribute instead.'
-        )
+    def data(self) -> Optional[bytes]:
+        """Byte string representing response content.
 
-    @body.setter
-    def body(self, value):
-        raise AttributeRemovedError(
-            'The body attribute is no longer supported. '
-            'Please use the text attribute instead.'
-        )
+        Use this attribute in lieu of `text` when your content is
+        already a byte string (of type ``bytes``). See also the note below.
 
-    @property
-    def data(self):
+        Warning:
+            Always use the `text` attribute for text, or encode it
+            first to ``bytes`` when using the `data` attribute, to
+            ensure Unicode characters are properly encoded in the
+            HTTP response.
+        """
         return self._data
 
     @data.setter
-    def data(self, value):
+    def data(self, value: Optional[bytes]) -> None:
         self._data = value
 
     @property
-    def headers(self):
+    def headers(self) -> Headers:
+        """Copy of all headers set for the response, without cookies.
+
+        Note that a new copy is created and returned each time this property is
+        referenced.
+        """
         return self._headers.copy()
 
     @property
-    def media(self):
+    def media(self) -> Any:
+        """A serializable object supported by the media handlers configured via
+        :class:`falcon.RequestOptions`.
+
+        Note:
+            See also :ref:`media` for more information regarding media
+            handling.
+        """  # noqa D205
         return self._media
 
     @media.setter
-    def media(self, value):
+    def media(self, value: Any) -> None:
         self._media = value
-        self._media_rendered = _UNSET
+        self._media_rendered = MISSING
 
-    def render_body(self):
+    def render_body(self) -> Optional[bytes]:
         """Get the raw bytestring content for the response body.
 
         This method returns the raw data for the HTTP response body, taking
@@ -255,15 +270,15 @@ class Response:
             finally the serialized value of the `media` attribute. If
             none of these attributes are set, ``None`` is returned.
         """
-
+        data: Optional[bytes]
         text = self.text
         if text is None:
             data = self._data
 
             if data is None and self._media is not None:
-                # NOTE(kgriffs): We use a special _UNSET singleton since
+                # NOTE(kgriffs): We use a special MISSING singleton since
                 #   None is ambiguous (the media handler might return None).
-                if self._media_rendered is _UNSET:
+                if self._media_rendered is MISSING:
                     if not self.content_type:
                         self.content_type = self.options.default_media_type
 
@@ -282,14 +297,16 @@ class Response:
                 data = text.encode()
             except AttributeError:
                 # NOTE(kgriffs): Assume it was a bytes object already
-                data = text
+                data = text  # type: ignore[assignment]
 
         return data
 
-    def __repr__(self):
-        return '<%s: %s>' % (self.__class__.__name__, self.status)
+    def __repr__(self) -> str:
+        return f'<{self.__class__.__name__}: {self.status}>'
 
-    def set_stream(self, stream, content_length):
+    def set_stream(
+        self, stream: Union[ReadableIO, Iterable[bytes]], content_length: int
+    ) -> None:
         """Set both `stream` and `content_length`.
 
         Although the :attr:`~falcon.Response.stream` and
@@ -321,17 +338,17 @@ class Response:
 
     def set_cookie(  # noqa: C901
         self,
-        name,
-        value,
-        expires=None,
-        max_age=None,
-        domain=None,
-        path=None,
-        secure=None,
-        http_only=True,
-        same_site=None,
-        partitioned=False,
-    ):
+        name: str,
+        value: str,
+        expires: Optional[datetime] = None,
+        max_age: Optional[int] = None,
+        domain: Optional[str] = None,
+        path: Optional[str] = None,
+        secure: Optional[bool] = None,
+        http_only: bool = True,
+        same_site: Optional[str] = None,
+        partitioned: bool = False,
+    ) -> None:
         """Set a response cookie.
 
         Note:
@@ -526,7 +543,13 @@ class Response:
         if partitioned:
             self._cookies[name]['partitioned'] = True
 
-    def unset_cookie(self, name, samesite='Lax', domain=None, path=None):
+    def unset_cookie(
+        self,
+        name: str,
+        samesite: str = 'Lax',
+        domain: Optional[str] = None,
+        path: Optional[str] = None,
+    ) -> None:
         """Unset a cookie in the response.
 
         Clears the contents of the cookie, and instructs the user
@@ -602,7 +625,13 @@ class Response:
         if path:
             self._cookies[name]['path'] = path
 
-    def get_header(self, name, default=None):
+    @overload
+    def get_header(self, name: str, default: str) -> str: ...
+
+    @overload
+    def get_header(self, name: str, default: Optional[str] = ...) -> Optional[str]: ...
+
+    def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
         """Retrieve the raw string value for the given header.
 
         Normally, when a header has multiple values, they will be
@@ -634,7 +663,7 @@ class Response:
 
         return self._headers.get(name, default)
 
-    def set_header(self, name, value):
+    def set_header(self, name: str, value: str) -> None:
         """Set a header for this response to a given value.
 
         Warning:
@@ -670,7 +699,7 @@ class Response:
 
         self._headers[name] = value
 
-    def delete_header(self, name):
+    def delete_header(self, name: str) -> None:
         """Delete a header that was previously set for this response.
 
         If the header was not previously set, nothing is done (no error is
@@ -704,7 +733,7 @@ class Response:
 
         self._headers.pop(name, None)
 
-    def append_header(self, name, value):
+    def append_header(self, name: str, value: str) -> None:
         """Set or append a header for this response.
 
         If the header already exists, the new value will normally be appended
@@ -744,7 +773,9 @@ class Response:
 
             self._headers[name] = value
 
-    def set_headers(self, headers):
+    def set_headers(
+        self, headers: Union[Mapping[str, str], Iterable[Tuple[str, str]]]
+    ) -> None:
         """Set several headers at once.
 
         This method can be used to set a collection of raw header names and
@@ -785,7 +816,7 @@ class Response:
         # normalize the header names.
         _headers = self._headers
 
-        for name, value in headers:
+        for name, value in headers:  # type: ignore[misc]
             # NOTE(kgriffs): uwsgi fails with a TypeError if any header
             # is not a str, so do the conversion here. It's actually
             # faster to not do an isinstance check. str() will encode
@@ -800,16 +831,16 @@ class Response:
 
     def append_link(
         self,
-        target,
-        rel,
-        title=None,
-        title_star=None,
-        anchor=None,
-        hreflang=None,
-        type_hint=None,
-        crossorigin=None,
-        link_extension=None,
-    ):
+        target: str,
+        rel: str,
+        title: Optional[str] = None,
+        title_star: Optional[Tuple[str, str]] = None,
+        anchor: Optional[str] = None,
+        hreflang: Optional[Union[str, Iterable[str]]] = None,
+        type_hint: Optional[str] = None,
+        crossorigin: Optional[str] = None,
+        link_extension: Optional[Iterable[Tuple[str, str]]] = None,
+    ) -> None:
         """Append a link header to the response.
 
         (See also: RFC 5988, Section 1)
@@ -834,7 +865,7 @@ class Response:
                 characters, you will need to use `title_star` instead, or
                 provide both a US-ASCII version using `title` and a
                 Unicode version using `title_star`.
-            title_star (tuple of str): Localized title describing the
+            title_star (tuple[str, str]): Localized title describing the
                 destination of the link (default ``None``). The value must be a
                 two-member tuple in the form of (*language-tag*, *text*),
                 where *language-tag* is a standard language identifier as
@@ -891,40 +922,34 @@ class Response:
             if ' ' in rel:
                 rel = '"' + ' '.join([uri_encode(r) for r in rel.split()]) + '"'
             else:
-                rel = '"' + uri_encode(rel) + '"'
+                rel = f'"{uri_encode(rel)}"'
 
         value = '<' + uri_encode(target) + '>; rel=' + rel
 
         if title is not None:
-            value += '; title="' + title + '"'
+            value += f'; title="{title}"'
 
         if title_star is not None:
-            value += (
-                "; title*=UTF-8'"
-                + title_star[0]
-                + "'"
-                + uri_encode_value(title_star[1])
-            )
+            value += f"; title*=UTF-8'{title_star[0]}'{uri_encode_value(title_star[1])}"
 
         if type_hint is not None:
-            value += '; type="' + type_hint + '"'
+            value += f'; type="{type_hint}"'
 
         if hreflang is not None:
             if isinstance(hreflang, str):
-                value += '; hreflang=' + hreflang
+                value += f'; hreflang={hreflang}'
             else:
                 value += '; '
                 value += '; '.join(['hreflang=' + lang for lang in hreflang])
 
         if anchor is not None:
-            value += '; anchor="' + uri_encode(anchor) + '"'
+            value += f'; anchor="{uri_encode(anchor)}"'
 
         if crossorigin is not None:
             crossorigin = crossorigin.lower()
             if crossorigin not in _RESERVED_CROSSORIGIN_VALUES:
                 raise ValueError(
-                    'crossorigin must be set to either '
-                    "'anonymous' or 'use-credentials'"
+                    "crossorigin must be set to either 'anonymous' or 'use-credentials'"
                 )
             if crossorigin == 'anonymous':
                 value += '; crossorigin'
@@ -935,32 +960,39 @@ class Response:
 
         if link_extension is not None:
             value += '; '
-            value += '; '.join([p + '=' + v for p, v in link_extension])
+            value += '; '.join([f'{p}={v}' for p, v in link_extension])
 
         _headers = self._headers
         if 'link' in _headers:
-            _headers['link'] += ', ' + value
+            _headers['link'] += f', {value}'
         else:
             _headers['link'] = value
 
-    # NOTE(kgriffs): Alias deprecated as of 3.0
-    add_link = deprecated('Please use append_link() instead.', method_name='add_link')(
-        append_link
-    )
+    @property
+    def add_link(self) -> NoReturn:
+        raise AttributeRemovedError(
+            'The add_link() method is no longer supported. '
+            'Please use append_link() instead.'
+        )
 
-    cache_control = header_property(
+    cache_control: Union[str, Iterable[str], None] = header_property(
         'Cache-Control',
         """Set the Cache-Control header.
 
         Used to set a list of cache directives to use as the value of the
         Cache-Control header. The list will be joined with ", " to produce
         the value for the header.
-
         """,
         format_header_value_list,
     )
+    """Set the Cache-Control header.
 
-    content_location = header_property(
+    Used to set a list of cache directives to use as the value of the
+    Cache-Control header. The list will be joined with ", " to produce
+    the value for the header.
+    """
+
+    content_location: Optional[str] = header_property(
         'Content-Location',
         """Set the Content-Location header.
 
@@ -970,8 +1002,14 @@ class Response:
         """,
         uri_encode,
     )
+    """Set the Content-Location header.
+
+    This value will be URI encoded per RFC 3986. If the value that is
+    being set is already URI encoded it should be decoded first or the
+    header should be set manually using the set_header method.
+    """
 
-    content_length = header_property(
+    content_length: Union[str, int, None] = header_property(
         'Content-Length',
         """Set the Content-Length header.
 
@@ -991,8 +1029,25 @@ class Response:
 
         """,
     )
+    """Set the Content-Length header.
+
+    This property can be used for responding to HEAD requests when you
+    aren't actually providing the response body, or when streaming the
+    response. If either the `text` property or the `data` property is set
+    on the response, the framework will force Content-Length to be the
+    length of the given text bytes. Therefore, it is only necessary to
+    manually set the content length when those properties are not used.
 
-    content_range = header_property(
+    Note:
+        In cases where the response content is a stream (readable
+        file-like object), Falcon will not supply a Content-Length header
+        to the server unless `content_length` is explicitly set.
+        Consequently, the server may choose to use chunked encoding in this
+        case.
+
+    """
+
+    content_range: Union[str, RangeSetHeader, None] = header_property(
         'Content-Range',
         """A tuple to use in constructing a value for the Content-Range header.
 
@@ -1012,8 +1067,24 @@ class Response:
         """,
         format_range,
     )
+    """A tuple to use in constructing a value for the Content-Range header.
+
+    The tuple has the form (*start*, *end*, *length*, [*unit*]), where *start* and
+    *end* designate the range (inclusive), and *length* is the
+    total length, or '\\*' if unknown. You may pass ``int``'s for
+    these numbers (no need to convert to ``str`` beforehand). The optional value
+    *unit* describes the range unit and defaults to 'bytes'
+
+    Note:
+        You only need to use the alternate form, 'bytes \\*/1234', for
+        responses that use the status '416 Range Not Satisfiable'. In this
+        case, raising ``falcon.HTTPRangeNotSatisfiable`` will do the right
+        thing.
+
+    (See also: RFC 7233, Section 4.2)
+    """
 
-    content_type = header_property(
+    content_type: Optional[str] = header_property(
         'Content-Type',
         """Sets the Content-Type header.
 
@@ -1026,8 +1097,18 @@ class Response:
         and ``falcon.MEDIA_GIF``.
         """,
     )
+    """Sets the Content-Type header.
+
+    The ``falcon`` module provides a number of constants for
+    common media types, including ``falcon.MEDIA_JSON``,
+    ``falcon.MEDIA_MSGPACK``, ``falcon.MEDIA_YAML``,
+    ``falcon.MEDIA_XML``, ``falcon.MEDIA_HTML``,
+    ``falcon.MEDIA_JS``, ``falcon.MEDIA_TEXT``,
+    ``falcon.MEDIA_JPEG``, ``falcon.MEDIA_PNG``,
+    and ``falcon.MEDIA_GIF``.
+    """
 
-    downloadable_as = header_property(
+    downloadable_as: Optional[str] = header_property(
         'Content-Disposition',
         """Set the Content-Disposition header using the given filename.
 
@@ -1042,8 +1123,19 @@ class Response:
         """,
         functools.partial(format_content_disposition, disposition_type='attachment'),
     )
+    """Set the Content-Disposition header using the given filename.
+
+    The value will be used for the ``filename`` directive. For example,
+    given ``'report.pdf'``, the Content-Disposition header would be set
+    to: ``'attachment; filename="report.pdf"'``.
 
-    viewable_as = header_property(
+    As per `RFC 6266 <https://tools.ietf.org/html/rfc6266#appendix-D>`_
+    recommendations, non-ASCII filenames will be encoded using the
+    ``filename*`` directive, whereas ``filename`` will contain the US
+    ASCII fallback.
+    """
+
+    viewable_as: Optional[str] = header_property(
         'Content-Disposition',
         """Set an inline Content-Disposition header using the given filename.
 
@@ -1060,8 +1152,21 @@ class Response:
         """,
         functools.partial(format_content_disposition, disposition_type='inline'),
     )
+    """Set an inline Content-Disposition header using the given filename.
+
+    The value will be used for the ``filename`` directive. For example,
+    given ``'report.pdf'``, the Content-Disposition header would be set
+    to: ``'inline; filename="report.pdf"'``.
+
+    As per `RFC 6266 <https://tools.ietf.org/html/rfc6266#appendix-D>`_
+    recommendations, non-ASCII filenames will be encoded using the
+    ``filename*`` directive, whereas ``filename`` will contain the US
+    ASCII fallback.
+
+    .. versionadded:: 3.1
+    """
 
-    etag = header_property(
+    etag: Optional[str] = header_property(
         'ETag',
         """Set the ETag header.
 
@@ -1070,8 +1175,13 @@ class Response:
         """,
         format_etag_header,
     )
+    """Set the ETag header.
 
-    expires = header_property(
+    The ETag header will be wrapped with double quotes ``"value"`` in case
+    the user didn't pass it.
+    """
+
+    expires: Union[str, datetime, None] = header_property(
         'Expires',
         """Set the Expires header. Set to a ``datetime`` (UTC) instance.
 
@@ -1080,8 +1190,13 @@ class Response:
         """,
         dt_to_http,
     )
+    """Set the Expires header. Set to a ``datetime`` (UTC) instance.
+
+    Note:
+        Falcon will format the ``datetime`` as an HTTP date string.
+    """
 
-    last_modified = header_property(
+    last_modified: Union[str, datetime, None] = header_property(
         'Last-Modified',
         """Set the Last-Modified header. Set to a ``datetime`` (UTC) instance.
 
@@ -1090,8 +1205,13 @@ class Response:
         """,
         dt_to_http,
     )
+    """Set the Last-Modified header. Set to a ``datetime`` (UTC) instance.
 
-    location = header_property(
+    Note:
+        Falcon will format the ``datetime`` as an HTTP date string.
+    """
+
+    location: Optional[str] = header_property(
         'Location',
         """Set the Location header.
 
@@ -1101,18 +1221,28 @@ class Response:
         """,
         uri_encode,
     )
+    """Set the Location header.
 
-    retry_after = header_property(
+    This value will be URI encoded per RFC 3986. If the value that is
+    being set is already URI encoded it should be decoded first or the
+    header should be set manually using the set_header method.
+    """
+
+    retry_after: Union[int, str, None] = header_property(
         'Retry-After',
         """Set the Retry-After header.
 
         The expected value is an integral number of seconds to use as the
         value for the header. The HTTP-date syntax is not supported.
         """,
-        str,
     )
+    """Set the Retry-After header.
+
+    The expected value is an integral number of seconds to use as the
+    value for the header. The HTTP-date syntax is not supported.
+    """
 
-    vary = header_property(
+    vary: Union[str, Iterable[str], None] = header_property(
         'Vary',
         """Value to use for the Vary header.
 
@@ -1131,8 +1261,23 @@ class Response:
         """,
         format_header_value_list,
     )
+    """Value to use for the Vary header.
+
+    Set this property to an iterable of header names. For a single
+    asterisk or field value, simply pass a single-element ``list``
+    or ``tuple``.
+
+    The "Vary" header field in a response describes what parts of
+    a request message, aside from the method, Host header field,
+    and request target, might influence the origin server's
+    process for selecting and representing this response.  The
+    value consists of either a single asterisk ("*") or a list of
+    header field names (case-insensitive).
 
-    accept_ranges = header_property(
+    (See also: RFC 7231, Section 7.1.4)
+    """
+
+    accept_ranges: Optional[str] = header_property(
         'Accept-Ranges',
         """Set the Accept-Ranges header.
 
@@ -1150,8 +1295,23 @@ class Response:
 
         """,
     )
+    """Set the Accept-Ranges header.
+
+    The Accept-Ranges header field indicates to the client which
+    range units are supported (e.g. "bytes") for the target
+    resource.
+
+    If range requests are not supported for the target resource,
+    the header may be set to "none" to advise the client not to
+    attempt any such requests.
+
+    Note:
+        "none" is the literal string, not Python's built-in ``None``
+        type.
+
+    """
 
-    def _set_media_type(self, media_type=None):
+    def _set_media_type(self, media_type: Optional[str] = None) -> None:
         """Set a content-type; wrapper around set_header.
 
         Args:
@@ -1166,7 +1326,7 @@ class Response:
         if media_type is not None and 'content-type' not in self._headers:
             self._headers['content-type'] = media_type
 
-    def _wsgi_headers(self, media_type=None):
+    def _wsgi_headers(self, media_type: Optional[str] = None) -> list[tuple[str, str]]:
         """Convert headers into the format expected by WSGI servers.
 
         Args:
@@ -1243,7 +1403,7 @@ class ResponseOptions:
         'static_media_types',
     )
 
-    def __init__(self):
+    def __init__(self) -> None:
         self.secure_cookies_by_default = True
         self.default_media_type = DEFAULT_MEDIA_TYPE
         self.media_handlers = Handlers()
diff --git a/falcon/response_helpers.py b/falcon/response_helpers.py
index 2e59ba78..8e1d9020 100644
--- a/falcon/response_helpers.py
+++ b/falcon/response_helpers.py
@@ -14,11 +14,21 @@
 
 """Utilities for the Response class."""
 
+from __future__ import annotations
+
+from typing import Any, Callable, Iterable, Optional, TYPE_CHECKING
+
+from falcon.typing import RangeSetHeader
 from falcon.util import uri
 from falcon.util.misc import secure_filename
 
+if TYPE_CHECKING:
+    from falcon import Response
+
 
-def header_property(name, doc, transform=None):
+def header_property(
+    name: str, doc: str, transform: Optional[Callable[[Any], str]] = None
+) -> Any:
     """Create a header getter/setter.
 
     Args:
@@ -32,7 +42,7 @@ def header_property(name, doc, transform=None):
     """
     normalized_name = name.lower()
 
-    def fget(self):
+    def fget(self: Response) -> Optional[str]:
         try:
             return self._headers[normalized_name]
         except KeyError:
@@ -40,7 +50,7 @@ def header_property(name, doc, transform=None):
 
     if transform is None:
 
-        def fset(self, value):
+        def fset(self: Response, value: Optional[Any]) -> None:
             if value is None:
                 try:
                     del self._headers[normalized_name]
@@ -51,7 +61,7 @@ def header_property(name, doc, transform=None):
 
     else:
 
-        def fset(self, value):
+        def fset(self: Response, value: Optional[Any]) -> None:
             if value is None:
                 try:
                     del self._headers[normalized_name]
@@ -60,31 +70,27 @@ def header_property(name, doc, transform=None):
             else:
                 self._headers[normalized_name] = transform(value)
 
-    def fdel(self):
+    def fdel(self: Response) -> None:
         del self._headers[normalized_name]
 
     return property(fget, fset, fdel, doc)
 
 
-def format_range(value):
+def format_range(value: RangeSetHeader) -> str:
     """Format a range header tuple per the HTTP spec.
 
     Args:
         value: ``tuple`` passed to `req.range`
     """
-
-    # PERF(kgriffs): % was found to be faster than str.format(),
-    # string concatenation, and str.join() in this case.
-
     if len(value) == 4:
-        result = '%s %s-%s/%s' % (value[3], value[0], value[1], value[2])
+        result = f'{value[3]} {value[0]}-{value[1]}/{value[2]}'
     else:
-        result = 'bytes %s-%s/%s' % (value[0], value[1], value[2])
+        result = f'bytes {value[0]}-{value[1]}/{value[2]}'
 
     return result
 
 
-def format_content_disposition(value, disposition_type='attachment'):
+def format_content_disposition(value: str, disposition_type: str = 'attachment') -> str:
     """Format a Content-Disposition header given a filename."""
 
     # NOTE(vytas): RFC 6266, Appendix D.
@@ -111,7 +117,7 @@ def format_content_disposition(value, disposition_type='attachment'):
     )
 
 
-def format_etag_header(value):
+def format_etag_header(value: str) -> str:
     """Format an ETag header, wrap it with " " in case of need."""
 
     if value[-1] != '"':
@@ -120,12 +126,12 @@ def format_etag_header(value):
     return value
 
 
-def format_header_value_list(iterable):
+def format_header_value_list(iterable: Iterable[str]) -> str:
     """Join an iterable of strings with commas."""
     return ', '.join(iterable)
 
 
-def is_ascii_encodable(s):
+def is_ascii_encodable(s: str) -> bool:
     """Check if argument encodes to ascii without error."""
     try:
         s.encode('ascii')
diff --git a/falcon/routing/__init__.py b/falcon/routing/__init__.py
index 9db1c486..80216570 100644
--- a/falcon/routing/__init__.py
+++ b/falcon/routing/__init__.py
@@ -29,7 +29,6 @@ from falcon.routing.converters import PathConverter
 from falcon.routing.converters import UUIDConverter
 from falcon.routing.static import StaticRoute
 from falcon.routing.static import StaticRouteAsync
-from falcon.routing.util import compile_uri_template
 from falcon.routing.util import map_http_methods
 from falcon.routing.util import set_default_responders
 
diff --git a/falcon/routing/compiled.py b/falcon/routing/compiled.py
index 443d0d4f..6407484c 100644
--- a/falcon/routing/compiled.py
+++ b/falcon/routing/compiled.py
@@ -240,7 +240,7 @@ class CompiledRouter:
             else:
                 return None
 
-        def insert(nodes: List[CompiledRouterNode], path_index: int = 0):
+        def insert(nodes: List[CompiledRouterNode], path_index: int = 0) -> None:
             for node in nodes:
                 segment = path[path_index]
                 if node.matches(segment):
@@ -351,12 +351,7 @@ class CompiledRouter:
             #   issue.
             if not iscoroutinefunction(responder) and is_python_func(responder):
                 if _should_wrap_non_coroutines():
-
-                    def let(responder=responder):
-                        method_map[method] = wrap_sync_to_async(responder)
-
-                    let()
-
+                    method_map[method] = wrap_sync_to_async(responder)
                 else:
                     msg = (
                         'The {} responder must be a non-blocking '
@@ -515,12 +510,13 @@ class CompiledRouter:
 
                 else:
                     # NOTE(kgriffs): Simple nodes just capture the entire path
-                    # segment as the value for the param.
+                    # segment as the value for the param. They have a var_name defined
 
+                    field_name = node.var_name
+                    assert field_name is not None
                     if node.var_converter_map:
                         assert len(node.var_converter_map) == 1
 
-                        field_name = node.var_name
                         __, converter_name, converter_argstr = node.var_converter_map[0]
                         converter_class = self._converter_map[converter_name]
 
@@ -547,7 +543,7 @@ class CompiledRouter:
                         parent.append_child(cx_converter)
                         parent = cx_converter
                     else:
-                        params_stack.append(_CxSetParamFromPath(node.var_name, level))
+                        params_stack.append(_CxSetParamFromPath(field_name, level))
 
                     # NOTE(kgriffs): We don't allow multiple simple var nodes
                     # to exist at the same level, e.g.:
@@ -745,7 +741,7 @@ class CompiledRouterNode:
         method_map: Optional[MethodDict] = None,
         resource: Optional[object] = None,
         uri_template: Optional[str] = None,
-    ):
+    ) -> None:
         self.children: List[CompiledRouterNode] = []
 
         self.raw_segment = raw_segment
@@ -833,12 +829,12 @@ class CompiledRouterNode:
         if self.is_complex:
             assert self.is_var
 
-    def matches(self, segment: str):
+    def matches(self, segment: str) -> bool:
         """Return True if this node matches the supplied template segment."""
 
         return segment == self.raw_segment
 
-    def conflicts_with(self, segment: str):
+    def conflicts_with(self, segment: str) -> bool:
         """Return True if this node conflicts with a given template segment."""
 
         # NOTE(kgriffs): This method assumes that the caller has already
@@ -900,11 +896,11 @@ class ConverterDict(UserDict):
 
     data: Dict[str, Type[converters.BaseConverter]]
 
-    def __setitem__(self, name, converter):
+    def __setitem__(self, name: str, converter: Type[converters.BaseConverter]) -> None:
         self._validate(name)
         UserDict.__setitem__(self, name, converter)
 
-    def _validate(self, name):
+    def _validate(self, name: str) -> None:
         if not _IDENTIFIER_PATTERN.match(name):
             raise ValueError(
                 'Invalid converter name. Names may not be blank, and may '
@@ -948,14 +944,14 @@ class CompiledRouterOptions:
 
     __slots__ = ('converters',)
 
-    def __init__(self):
+    def __init__(self) -> None:
         object.__setattr__(
             self,
             'converters',
             ConverterDict((name, converter) for name, converter in converters.BUILTIN),
         )
 
-    def __setattr__(self, name, value) -> None:
+    def __setattr__(self, name: str, value: Any) -> None:
         if name == 'converters':
             raise AttributeError('Cannot set "converters", please update it in place.')
         super().__setattr__(name, value)
@@ -978,13 +974,13 @@ class _CxParent:
     def __init__(self) -> None:
         self._children: List[_CxElement] = []
 
-    def append_child(self, construct: _CxElement):
+    def append_child(self, construct: _CxElement) -> None:
         self._children.append(construct)
 
     def src(self, indentation: int) -> str:
         return self._children_src(indentation + 1)
 
-    def _children_src(self, indentation):
+    def _children_src(self, indentation: int) -> str:
         src_lines = [child.src(indentation) for child in self._children]
 
         return '\n'.join(src_lines)
@@ -997,12 +993,12 @@ class _CxChild:
 
 
 class _CxIfPathLength(_CxParent):
-    def __init__(self, comparison, length):
+    def __init__(self, comparison: str, length: int) -> None:
         super().__init__()
         self._comparison = comparison
         self._length = length
 
-    def src(self, indentation):
+    def src(self, indentation: int) -> str:
         template = '{0}if path_len {1} {2}:\n{3}'
         return template.format(
             _TAB_STR * indentation,
@@ -1013,12 +1009,12 @@ class _CxIfPathLength(_CxParent):
 
 
 class _CxIfPathSegmentLiteral(_CxParent):
-    def __init__(self, segment_idx, literal):
+    def __init__(self, segment_idx: int, literal: str) -> None:
         super().__init__()
         self._segment_idx = segment_idx
         self._literal = literal
 
-    def src(self, indentation):
+    def src(self, indentation: int) -> str:
         template = "{0}if path[{1}] == '{2}':\n{3}"
         return template.format(
             _TAB_STR * indentation,
@@ -1029,13 +1025,13 @@ class _CxIfPathSegmentLiteral(_CxParent):
 
 
 class _CxIfPathSegmentPattern(_CxParent):
-    def __init__(self, segment_idx, pattern_idx, pattern_text):
+    def __init__(self, segment_idx: int, pattern_idx: int, pattern_text: str) -> None:
         super().__init__()
         self._segment_idx = segment_idx
         self._pattern_idx = pattern_idx
         self._pattern_text = pattern_text
 
-    def src(self, indentation):
+    def src(self, indentation: int) -> str:
         lines = [
             '{0}match = patterns[{1}].match(path[{2}])  # {3}'.format(
                 _TAB_STR * indentation,
@@ -1051,13 +1047,13 @@ class _CxIfPathSegmentPattern(_CxParent):
 
 
 class _CxIfConverterField(_CxParent):
-    def __init__(self, unique_idx, converter_idx):
+    def __init__(self, unique_idx: int, converter_idx: int) -> None:
         super().__init__()
         self._converter_idx = converter_idx
         self._unique_idx = unique_idx
         self.field_variable_name = 'field_value_{0}'.format(unique_idx)
 
-    def src(self, indentation):
+    def src(self, indentation: int) -> str:
         lines = [
             '{0}{1} = converters[{2}].convert(fragment)'.format(
                 _TAB_STR * indentation,
@@ -1074,10 +1070,10 @@ class _CxIfConverterField(_CxParent):
 
 
 class _CxSetFragmentFromField(_CxChild):
-    def __init__(self, field_name):
+    def __init__(self, field_name: str) -> None:
         self._field_name = field_name
 
-    def src(self, indentation):
+    def src(self, indentation: int) -> str:
         return "{0}fragment = groups.pop('{1}')".format(
             _TAB_STR * indentation,
             self._field_name,
@@ -1085,10 +1081,10 @@ class _CxSetFragmentFromField(_CxChild):
 
 
 class _CxSetFragmentFromPath(_CxChild):
-    def __init__(self, segment_idx):
+    def __init__(self, segment_idx: int) -> None:
         self._segment_idx = segment_idx
 
-    def src(self, indentation):
+    def src(self, indentation: int) -> str:
         return '{0}fragment = path[{1}]'.format(
             _TAB_STR * indentation,
             self._segment_idx,
@@ -1096,10 +1092,10 @@ class _CxSetFragmentFromPath(_CxChild):
 
 
 class _CxSetFragmentFromRemainingPaths(_CxChild):
-    def __init__(self, segment_idx):
+    def __init__(self, segment_idx: int) -> None:
         self._segment_idx = segment_idx
 
-    def src(self, indentation):
+    def src(self, indentation: int) -> str:
         return '{0}fragment = path[{1}:]'.format(
             _TAB_STR * indentation,
             self._segment_idx,
@@ -1107,51 +1103,51 @@ class _CxSetFragmentFromRemainingPaths(_CxChild):
 
 
 class _CxVariableFromPatternMatch(_CxChild):
-    def __init__(self, unique_idx):
+    def __init__(self, unique_idx: int) -> None:
         self._unique_idx = unique_idx
         self.dict_variable_name = 'dict_match_{0}'.format(unique_idx)
 
-    def src(self, indentation):
+    def src(self, indentation: int) -> str:
         return '{0}{1} = match.groupdict()'.format(
             _TAB_STR * indentation, self.dict_variable_name
         )
 
 
 class _CxVariableFromPatternMatchPrefetched(_CxChild):
-    def __init__(self, unique_idx):
+    def __init__(self, unique_idx: int) -> None:
         self._unique_idx = unique_idx
         self.dict_variable_name = 'dict_groups_{0}'.format(unique_idx)
 
-    def src(self, indentation):
+    def src(self, indentation: int) -> str:
         return '{0}{1} = groups'.format(_TAB_STR * indentation, self.dict_variable_name)
 
 
 class _CxPrefetchGroupsFromPatternMatch(_CxChild):
-    def src(self, indentation):
+    def src(self, indentation: int) -> str:
         return '{0}groups = match.groupdict()'.format(_TAB_STR * indentation)
 
 
 class _CxReturnNone(_CxChild):
-    def src(self, indentation):
+    def src(self, indentation: int) -> str:
         return '{0}return None'.format(_TAB_STR * indentation)
 
 
 class _CxReturnValue(_CxChild):
-    def __init__(self, value_idx):
+    def __init__(self, value_idx: int) -> None:
         self._value_idx = value_idx
 
-    def src(self, indentation):
+    def src(self, indentation: int) -> str:
         return '{0}return return_values[{1}]'.format(
             _TAB_STR * indentation, self._value_idx
         )
 
 
 class _CxSetParamFromPath(_CxChild):
-    def __init__(self, param_name, segment_idx):
+    def __init__(self, param_name: str, segment_idx: int) -> None:
         self._param_name = param_name
         self._segment_idx = segment_idx
 
-    def src(self, indentation):
+    def src(self, indentation: int) -> str:
         return "{0}params['{1}'] = path[{2}]".format(
             _TAB_STR * indentation,
             self._param_name,
@@ -1160,11 +1156,11 @@ class _CxSetParamFromPath(_CxChild):
 
 
 class _CxSetParamFromValue(_CxChild):
-    def __init__(self, param_name, field_value_name):
+    def __init__(self, param_name: str, field_value_name: str) -> None:
         self._param_name = param_name
         self._field_value_name = field_value_name
 
-    def src(self, indentation):
+    def src(self, indentation: int) -> str:
         return "{0}params['{1}'] = {2}".format(
             _TAB_STR * indentation,
             self._param_name,
@@ -1173,10 +1169,10 @@ class _CxSetParamFromValue(_CxChild):
 
 
 class _CxSetParamsFromDict(_CxChild):
-    def __init__(self, dict_value_name):
+    def __init__(self, dict_value_name: str) -> None:
         self._dict_value_name = dict_value_name
 
-    def src(self, indentation):
+    def src(self, indentation: int) -> str:
         return '{0}params.update({1})'.format(
             _TAB_STR * indentation,
             self._dict_value_name,
diff --git a/falcon/routing/converters.py b/falcon/routing/converters.py
index 2d2bc7fa..d50d6b85 100644
--- a/falcon/routing/converters.py
+++ b/falcon/routing/converters.py
@@ -11,11 +11,12 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
+from __future__ import annotations
 
 import abc
 from datetime import datetime
 from math import isfinite
-from typing import Optional
+from typing import Any, ClassVar, Iterable, Optional, overload, Union
 import uuid
 
 __all__ = (
@@ -34,7 +35,7 @@ strptime = datetime.strptime
 class BaseConverter(metaclass=abc.ABCMeta):
     """Abstract base class for URI template field converters."""
 
-    CONSUME_MULTIPLE_SEGMENTS = False
+    CONSUME_MULTIPLE_SEGMENTS: ClassVar[bool] = False
     """When set to ``True`` it indicates that this converter will consume
     multiple URL path segments. Currently a converter with
     ``CONSUME_MULTIPLE_SEGMENTS=True`` must be at the end of the URL template
@@ -42,8 +43,8 @@ class BaseConverter(metaclass=abc.ABCMeta):
     segments.
     """
 
-    @abc.abstractmethod  # pragma: no cover
-    def convert(self, value):
+    @abc.abstractmethod
+    def convert(self, value: str) -> Any:
         """Convert a URI template field value to another format or type.
 
         Args:
@@ -76,14 +77,19 @@ class IntConverter(BaseConverter):
 
     __slots__ = ('_num_digits', '_min', '_max')
 
-    def __init__(self, num_digits=None, min=None, max=None):
+    def __init__(
+        self,
+        num_digits: Optional[int] = None,
+        min: Optional[int] = None,
+        max: Optional[int] = None,
+    ) -> None:
         if num_digits is not None and num_digits < 1:
             raise ValueError('num_digits must be at least 1')
         self._num_digits = num_digits
         self._min = min
         self._max = max
 
-    def convert(self, value):
+    def convert(self, value: str) -> Optional[int]:
         if self._num_digits is not None and len(value) != self._num_digits:
             return None
 
@@ -96,22 +102,35 @@ class IntConverter(BaseConverter):
             return None
 
         try:
-            value = int(value)
+            converted = int(value)
         except ValueError:
             return None
 
-        return self._validate_min_max_value(value)
+        return _validate_min_max_value(self, converted)
 
-    def _validate_min_max_value(self, value):
-        if self._min is not None and value < self._min:
-            return None
-        if self._max is not None and value > self._max:
-            return None
 
-        return value
+@overload
+def _validate_min_max_value(converter: IntConverter, value: int) -> Optional[int]: ...
+
+
+@overload
+def _validate_min_max_value(
+    converter: FloatConverter, value: float
+) -> Optional[float]: ...
+
+
+def _validate_min_max_value(
+    converter: Union[IntConverter, FloatConverter], value: Union[int, float]
+) -> Optional[Union[int, float]]:
+    if converter._min is not None and value < converter._min:
+        return None
+    if converter._max is not None and value > converter._max:
+        return None
+
+    return value
 
 
-class FloatConverter(IntConverter):
+class FloatConverter(BaseConverter):
     """Converts a field value to an float.
 
     Identifier: `float`
@@ -124,19 +143,19 @@ class FloatConverter(IntConverter):
             nan, inf, and -inf in addition to finite numbers.
     """
 
-    __slots__ = '_finite'
+    __slots__ = '_finite', '_min', '_max'
 
     def __init__(
         self,
         min: Optional[float] = None,
         max: Optional[float] = None,
         finite: bool = True,
-    ):
+    ) -> None:
         self._min = min
         self._max = max
         self._finite = finite if finite is not None else True
 
-    def convert(self, value: str):
+    def convert(self, value: str) -> Optional[float]:
         if value.strip() != value:
             return None
 
@@ -149,7 +168,7 @@ class FloatConverter(IntConverter):
         except ValueError:
             return None
 
-        return self._validate_min_max_value(converted)
+        return _validate_min_max_value(self, converted)
 
 
 class DateTimeConverter(BaseConverter):
@@ -165,10 +184,10 @@ class DateTimeConverter(BaseConverter):
 
     __slots__ = ('_format_string',)
 
-    def __init__(self, format_string='%Y-%m-%dT%H:%M:%SZ'):
+    def __init__(self, format_string: str = '%Y-%m-%dT%H:%M:%SZ') -> None:
         self._format_string = format_string
 
-    def convert(self, value):
+    def convert(self, value: str) -> Optional[datetime]:
         try:
             return strptime(value, self._format_string)
         except ValueError:
@@ -185,7 +204,7 @@ class UUIDConverter(BaseConverter):
     Note, however, that hyphens and the URN prefix are optional.
     """
 
-    def convert(self, value):
+    def convert(self, value: str) -> Optional[uuid.UUID]:
         try:
             return uuid.UUID(value)
         except ValueError:
@@ -213,7 +232,7 @@ class PathConverter(BaseConverter):
 
     CONSUME_MULTIPLE_SEGMENTS = True
 
-    def convert(self, value):
+    def convert(self, value: Iterable[str]) -> str:
         return '/'.join(value)
 
 
diff --git a/falcon/routing/util.py b/falcon/routing/util.py
index 3d254acc..eb709a44 100644
--- a/falcon/routing/util.py
+++ b/falcon/routing/util.py
@@ -16,95 +16,22 @@
 
 from __future__ import annotations
 
-import re
-from typing import Callable, Dict, Optional
+from typing import Optional, TYPE_CHECKING
 
 from falcon import constants
 from falcon import responders
-from falcon.util.deprecation import deprecated
+
+if TYPE_CHECKING:
+    from falcon.typing import MethodDict
 
 
 class SuffixedMethodNotFoundError(Exception):
-    def __init__(self, message):
+    def __init__(self, message: str) -> None:
         super(SuffixedMethodNotFoundError, self).__init__(message)
         self.message = message
 
 
-# NOTE(kgriffs): Published method; take care to avoid breaking changes.
-@deprecated('This method will be removed in Falcon 4.0.')
-def compile_uri_template(template):
-    """Compile the given URI template string into a pattern matcher.
-
-    This function can be used to construct custom routing engines that
-    iterate through a list of possible routes, attempting to match
-    an incoming request against each route's compiled regular expression.
-
-    Each field is converted to a named group, so that when a match
-    is found, the fields can be easily extracted using
-    :meth:`re.MatchObject.groupdict`.
-
-    This function does not support the more flexible templating
-    syntax used in the default router. Only simple paths with bracketed
-    field expressions are recognized. For example::
-
-        /
-        /books
-        /books/{isbn}
-        /books/{isbn}/characters
-        /books/{isbn}/characters/{name}
-
-    Warning:
-        If the template contains a trailing slash character, it will be
-        stripped.
-
-        Note that this is **different** from :ref:`the default behavior
-        <trailing_slash_in_path>` of :func:`~falcon.App.add_route` used
-        with the default :class:`~falcon.routing.CompiledRouter`.
-
-        The :attr:`~falcon.RequestOptions.strip_url_path_trailing_slash`
-        request option is not considered by ``compile_uri_template()``.
-
-
-    Args:
-        template(str): The template to compile. Note that field names are
-            restricted to ASCII a-z, A-Z, and the underscore character.
-
-    Returns:
-        tuple: (template_field_names, template_regex)
-
-    .. deprecated:: 3.1
-    """
-
-    if not isinstance(template, str):
-        raise TypeError('uri_template is not a string')
-
-    if not template.startswith('/'):
-        raise ValueError("uri_template must start with '/'")
-
-    if '//' in template:
-        raise ValueError("uri_template may not contain '//'")
-
-    if template != '/' and template.endswith('/'):
-        template = template[:-1]
-
-    # template names should be able to start with A-Za-z
-    # but also contain 0-9_ in the remaining portion
-    expression_pattern = r'{([a-zA-Z]\w*)}'
-
-    # Get a list of field names
-    fields = set(re.findall(expression_pattern, template))
-
-    # Convert Level 1 var patterns to equivalent named regex groups
-    escaped = re.sub(r'[\.\(\)\[\]\?\*\+\^\|]', r'\\\g<0>', template)
-    pattern = re.sub(expression_pattern, r'(?P<\1>[^/]+)', escaped)
-    pattern = r'\A' + pattern + r'\Z'
-
-    return fields, re.compile(pattern, re.IGNORECASE)
-
-
-def map_http_methods(
-    resource: object, suffix: Optional[str] = None
-) -> Dict[str, Callable]:
+def map_http_methods(resource: object, suffix: Optional[str] = None) -> MethodDict:
     """Map HTTP methods (e.g., GET, POST) to methods of a resource object.
 
     Args:
@@ -151,7 +78,7 @@ def map_http_methods(
     return method_map
 
 
-def set_default_responders(method_map, asgi=False):
+def set_default_responders(method_map: MethodDict, asgi: bool = False) -> None:
     """Map HTTP methods not explicitly defined on a resource to default responders.
 
     Args:
@@ -169,11 +96,11 @@ def set_default_responders(method_map, asgi=False):
     if 'OPTIONS' not in method_map:
         # OPTIONS itself is intentionally excluded from the Allow header
         opt_responder = responders.create_default_options(allowed_methods, asgi=asgi)
-        method_map['OPTIONS'] = opt_responder
+        method_map['OPTIONS'] = opt_responder  # type: ignore[assignment]
         allowed_methods.append('OPTIONS')
 
     na_responder = responders.create_method_not_allowed(allowed_methods, asgi=asgi)
 
     for method in constants.COMBINED_METHODS:
         if method not in method_map:
-            method_map[method] = na_responder
+            method_map[method] = na_responder  # type: ignore[assignment]
diff --git a/falcon/status_codes.py b/falcon/status_codes.py
index 80c0b5f8..8c49f265 100644
--- a/falcon/status_codes.py
+++ b/falcon/status_codes.py
@@ -23,6 +23,8 @@ HTTP_101: Final[str] = '101 Switching Protocols'
 HTTP_SWITCHING_PROTOCOLS: Final[str] = HTTP_101
 HTTP_102: Final[str] = '102 Processing'
 HTTP_PROCESSING: Final[str] = HTTP_102
+HTTP_103: Final[str] = '103 Early Hints'
+HTTP_EARLY_HINTS: Final[str] = HTTP_103
 
 # 2xx - Success
 HTTP_200: Final[str] = '200 OK'
@@ -103,12 +105,16 @@ HTTP_417: Final[str] = '417 Expectation Failed'
 HTTP_EXPECTATION_FAILED: Final[str] = HTTP_417
 HTTP_418: Final[str] = "418 I'm a teapot"
 HTTP_IM_A_TEAPOT: Final[str] = HTTP_418
+HTTP_421: Final[str] = '421 Misdirected Request'
+HTTP_MISDIRECTED_REQUEST: Final[str] = HTTP_421
 HTTP_422: Final[str] = '422 Unprocessable Entity'
 HTTP_UNPROCESSABLE_ENTITY: Final[str] = HTTP_422
 HTTP_423: Final[str] = '423 Locked'
 HTTP_LOCKED: Final[str] = HTTP_423
 HTTP_424: Final[str] = '424 Failed Dependency'
 HTTP_FAILED_DEPENDENCY: Final[str] = HTTP_424
+HTTP_425: Final[str] = '425 Too Early'
+HTTP_TOO_EARLY: Final[str] = HTTP_425
 HTTP_426: Final[str] = '426 Upgrade Required'
 HTTP_UPGRADE_REQUIRED: Final[str] = HTTP_426
 HTTP_428: Final[str] = '428 Precondition Required'
@@ -133,10 +139,14 @@ HTTP_504: Final[str] = '504 Gateway Timeout'
 HTTP_GATEWAY_TIMEOUT: Final[str] = HTTP_504
 HTTP_505: Final[str] = '505 HTTP Version Not Supported'
 HTTP_HTTP_VERSION_NOT_SUPPORTED: Final[str] = HTTP_505
+HTTP_506: Final[str] = '506 Variant Also Negotiates'
+HTTP_VARIANT_ALSO_NEGOTIATES: Final[str] = HTTP_506
 HTTP_507: Final[str] = '507 Insufficient Storage'
 HTTP_INSUFFICIENT_STORAGE: Final[str] = HTTP_507
 HTTP_508: Final[str] = '508 Loop Detected'
 HTTP_LOOP_DETECTED: Final[str] = HTTP_508
+HTTP_510: Final[str] = '510 Not Extended'
+HTTP_NOT_EXTENDED: Final[str] = HTTP_510
 HTTP_511: Final[str] = '511 Network Authentication Required'
 HTTP_NETWORK_AUTHENTICATION_REQUIRED: Final[str] = HTTP_511
 
@@ -209,6 +219,7 @@ __all__ = (
     'HTTP_100',
     'HTTP_101',
     'HTTP_102',
+    'HTTP_103',
     'HTTP_200',
     'HTTP_201',
     'HTTP_202',
@@ -246,9 +257,11 @@ __all__ = (
     'HTTP_416',
     'HTTP_417',
     'HTTP_418',
+    'HTTP_421',
     'HTTP_422',
     'HTTP_423',
     'HTTP_424',
+    'HTTP_425',
     'HTTP_426',
     'HTTP_428',
     'HTTP_429',
@@ -260,8 +273,10 @@ __all__ = (
     'HTTP_503',
     'HTTP_504',
     'HTTP_505',
+    'HTTP_506',
     'HTTP_507',
     'HTTP_508',
+    'HTTP_510',
     'HTTP_511',
     'HTTP_701',
     'HTTP_702',
@@ -317,6 +332,7 @@ __all__ = (
     'HTTP_CONFLICT',
     'HTTP_CONTINUE',
     'HTTP_CREATED',
+    'HTTP_EARLY_HINTS',
     'HTTP_EXPECTATION_FAILED',
     'HTTP_FAILED_DEPENDENCY',
     'HTTP_FORBIDDEN',
@@ -332,12 +348,14 @@ __all__ = (
     'HTTP_LOCKED',
     'HTTP_LOOP_DETECTED',
     'HTTP_METHOD_NOT_ALLOWED',
+    'HTTP_MISDIRECTED_REQUEST',
     'HTTP_MOVED_PERMANENTLY',
     'HTTP_MULTIPLE_CHOICES',
     'HTTP_MULTI_STATUS',
     'HTTP_NETWORK_AUTHENTICATION_REQUIRED',
     'HTTP_NON_AUTHORITATIVE_INFORMATION',
     'HTTP_NOT_ACCEPTABLE',
+    'HTTP_NOT_EXTENDED',
     'HTTP_NOT_FOUND',
     'HTTP_NOT_IMPLEMENTED',
     'HTTP_NOT_MODIFIED',
@@ -360,6 +378,7 @@ __all__ = (
     'HTTP_SERVICE_UNAVAILABLE',
     'HTTP_SWITCHING_PROTOCOLS',
     'HTTP_TEMPORARY_REDIRECT',
+    'HTTP_TOO_EARLY',
     'HTTP_TOO_MANY_REQUESTS',
     'HTTP_UNAUTHORIZED',
     'HTTP_UNAVAILABLE_FOR_LEGAL_REASONS',
@@ -367,4 +386,5 @@ __all__ = (
     'HTTP_UNSUPPORTED_MEDIA_TYPE',
     'HTTP_UPGRADE_REQUIRED',
     'HTTP_USE_PROXY',
+    'HTTP_VARIANT_ALSO_NEGOTIATES',
 )
diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py
index e2196112..05513b88 100644
--- a/falcon/testing/helpers.py
+++ b/falcon/testing/helpers.py
@@ -785,10 +785,10 @@ class ASGIWebSocketSimulator:
         else:
             assert self.closed
 
-            # NOTE(kgriffs): According to the ASGI spec, we are
-            #   supposed to just silently eat events once the
-            #   socket is disconnected.
-            pass
+            # NOTE(vytas): Tweaked in Falcon 4.0: we now simulate ASGI
+            #   WebSocket protocol 2.4+, raising an instance of OSError upon
+            #   send if the client has already disconnected.
+            raise falcon_errors.WebSocketDisconnected(self._close_code)
 
         # NOTE(kgriffs): Give whatever is waiting on the handshake or a
         #   collected data/text event a chance to progress.
diff --git a/falcon/typing.py b/falcon/typing.py
index c7e41766..a548b32a 100644
--- a/falcon/typing.py
+++ b/falcon/typing.py
@@ -48,6 +48,7 @@ else:
 if TYPE_CHECKING:
     from falcon.asgi import Request as AsgiRequest
     from falcon.asgi import Response as AsgiResponse
+    from falcon.asgi import SSEvent
     from falcon.asgi import WebSocket
     from falcon.asgi_spec import AsgiEvent
     from falcon.asgi_spec import AsgiSendMsg
@@ -120,6 +121,7 @@ HeaderList = Union[Headers, List[Tuple[str, str]]]
 ResponseStatus = Union[http.HTTPStatus, str, int]
 StoreArgument = Optional[Dict[str, Any]]
 Resource = object
+RangeSetHeader = Union[Tuple[int, int, int], Tuple[int, int, int, str]]
 
 
 class ResponderMethod(Protocol):
@@ -177,6 +179,11 @@ AsgiProcessRequestWsMethod = Callable[['AsgiRequest', 'WebSocket'], Awaitable[No
 AsgiProcessResourceWsMethod = Callable[
     ['AsgiRequest', 'WebSocket', Resource, Dict[str, Any]], Awaitable[None]
 ]
+SseEmitter = AsyncIterator[Optional['SSEvent']]
+ResponseCallbacks = Union[
+    Tuple[Callable[[], None], Literal[False]],
+    Tuple[Callable[[], Awaitable[None]], Literal[True]],
+]
 
 
 class AsgiResponderCallable(Protocol):
diff --git a/falcon/util/__init__.py b/falcon/util/__init__.py
index 03d810e9..9c5ad414 100644
--- a/falcon/util/__init__.py
+++ b/falcon/util/__init__.py
@@ -34,7 +34,6 @@ from falcon.util.misc import code_to_http_status
 from falcon.util.misc import dt_to_http
 from falcon.util.misc import get_argnames
 from falcon.util.misc import get_bound_method
-from falcon.util.misc import get_http_status
 from falcon.util.misc import http_date_to_dt
 from falcon.util.misc import http_now
 from falcon.util.misc import http_status_to_code
@@ -80,18 +79,3 @@ except ImportError:
 BufferedReader = (
     (_CyBufferedReader or _PyBufferedReader) if IS_64_BITS else _PyBufferedReader
 )
-
-
-def __getattr__(name: str) -> ModuleType:
-    if name == 'json':
-        import json  # NOQA
-        import warnings
-
-        warnings.warn(
-            'Importing json from "falcon.util" is deprecated.', DeprecatedWarning
-        )
-        return json
-
-    # fallback to the default implementation
-    mod = sys.modules[__name__]
-    return ModuleType.__getattr__(mod, name)
diff --git a/falcon/util/misc.py b/falcon/util/misc.py
index 1fbe09ef..051d6269 100644
--- a/falcon/util/misc.py
+++ b/falcon/util/misc.py
@@ -55,7 +55,6 @@ __all__ = (
     'to_query_str',
     'get_bound_method',
     'get_argnames',
-    'get_http_status',
     'http_status_to_code',
     'code_to_http_status',
     'secure_filename',
@@ -331,47 +330,6 @@ def get_argnames(func: Callable[..., Any]) -> List[str]:
     return args
 
 
-@deprecated('Please use falcon.code_to_http_status() instead.')
-def get_http_status(
-    status_code: Union[str, int], default_reason: str = _DEFAULT_HTTP_REASON
-) -> str:
-    """Get both the http status code and description from just a code.
-
-    Warning:
-        As of Falcon 3.0, this method has been deprecated in favor of
-        :meth:`~falcon.code_to_http_status`.
-
-    Args:
-        status_code: integer or string that can be converted to an integer
-        default_reason: default text to be appended to the status_code
-            if the lookup does not find a result
-
-    Returns:
-        str: status code e.g. "404 Not Found"
-
-    Raises:
-        ValueError: the value entered could not be converted to an integer
-
-    """
-    # sanitize inputs
-    try:
-        code = float(status_code)  # float can validate values like "401.1"
-        code = int(code)  # converting to int removes the decimal places
-        if code < 100:
-            raise ValueError
-    except ValueError:
-        raise ValueError(
-            'get_http_status failed: "%s" is not a valid status code', status_code
-        )
-
-    # lookup the status code
-    try:
-        return getattr(status_codes, 'HTTP_' + str(code))
-    except AttributeError:
-        # not found
-        return str(code) + ' ' + default_reason
-
-
 def secure_filename(filename: Optional[str]) -> str:
     """Sanitize the provided `filename` to contain only ASCII characters.
 
@@ -461,9 +419,8 @@ def code_to_http_status(status: Union[int, http.HTTPStatus, bytes, str]) -> str:
     An LRU is used to minimize lookup time.
 
     Note:
-        Unlike the deprecated :func:`get_http_status`, this function will not
-        attempt to coerce a string status to an integer code, assuming the
-        string already denotes an HTTP status line.
+        This function will not attempt to coerce a string status to an
+        integer code, assuming the string already denotes an HTTP status line.
 
     Args:
         status: The status code or enum to normalize.
@@ -519,6 +476,6 @@ def _encode_items_to_latin1(data: Dict[str, str]) -> List[Tuple[bytes, bytes]]:
 
 _encode_items_to_latin1 = _cy_encode_items_to_latin1 or _encode_items_to_latin1
 
-isascii = deprecated('This will be removed in V5. Please use `str.isascii`')(
-    str.isascii
-)
+isascii = deprecated(
+    'This method will be removed in Falcon 5.0; please use str.isascii() instead.'
+)(str.isascii)
diff --git a/falcon/util/sync.py b/falcon/util/sync.py
index 0bfc2402..6d9d4e45 100644
--- a/falcon/util/sync.py
+++ b/falcon/util/sync.py
@@ -6,7 +6,7 @@ import inspect
 import os
 from typing import Any, Awaitable, Callable, Optional, TypeVar, Union
 
-from falcon.util import deprecated
+from falcon.util import deprecation
 
 __all__ = (
     'async_to_sync',
@@ -56,11 +56,13 @@ class _ActiveRunner:
 _active_runner = _ActiveRunner(getattr(asyncio, 'Runner', _DummyRunner))
 _one_thread_to_rule_them_all = ThreadPoolExecutor(max_workers=1)
 
-create_task = deprecated(
-    'This will be removed in V5. Please use `asyncio.create_task`'
+create_task = deprecation.deprecated(
+    'This alias is deprecated; it will be removed in Falcon 5.0. '
+    'Please use asyncio.create_task() directly.'
 )(asyncio.create_task)
-get_running_loop = deprecated(
-    'This will be removed in V5. Please use `asyncio.get_running_loop`'
+get_running_loop = deprecation.deprecated(
+    'This alias is deprecated; it will be removed in Falcon 5.0. '
+    'Please use asyncio.get_running_loop() directly.'
 )(asyncio.get_running_loop)
 
 
diff --git a/falcon/util/time.py b/falcon/util/time.py
index 6e4cce99..952cbb3b 100644
--- a/falcon/util/time.py
+++ b/falcon/util/time.py
@@ -22,7 +22,10 @@ class TimezoneGMT(datetime.tzinfo):
 
     GMT_ZERO = datetime.timedelta(hours=0)
 
-    @deprecated('TimezoneGMT is deprecated, use datetime.timezone.utc instead')
+    @deprecated(
+        'TimezoneGMT is deprecated, use datetime.timezone.utc instead. '
+        '(TimezoneGMT will be removed in Falcon 5.0.)'
+    )
     def __init__(self) -> None:
         super().__init__()
 
diff --git a/pyproject.toml b/pyproject.toml
index 5a9040ba..ee74ee31 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -116,13 +116,7 @@ exclude = ["examples", "tests"]
 
     [[tool.mypy.overrides]]
         module = [
-            "falcon.asgi.response",
             "falcon.media.validators.*",
-            "falcon.responders",
-            "falcon.response_helpers",
-            "falcon.response",
-            "falcon.routing.*",
-            "falcon.routing.converters",
             "falcon.testing.*",
             "falcon.vendor.*",
         ]
diff --git a/requirements/docs b/requirements/docs
index 4e4fece5..1eb338fb 100644
--- a/requirements/docs
+++ b/requirements/docs
@@ -1,11 +1,8 @@
-docutils
 doc2dash
-jinja2
-markupsafe
-pygments
+falconry-pygments-theme >= 0.2.0
 myst-parser
-pygments-style-github
+pydata-sphinx-theme
 PyYAML
-sphinx
-sphinx_rtd_theme
-sphinx-tabs
+Sphinx
+sphinx-copybutton
+sphinx_design
diff --git a/requirements/tests b/requirements/tests
index 5be00de3..ada7c372 100644
--- a/requirements/tests
+++ b/requirements/tests
@@ -22,7 +22,8 @@ mujson
 ujson
 
 # it's slow to compile on emulated architectures; wheels missing for some EoL interpreters
-python-rapidjson; platform_machine != 's390x' and platform_machine != 'aarch64'
+# (and there is a new issue with building on PyPy in Actions, but we don't really need to test it with PyPy)
+python-rapidjson; platform_python_implementation != 'PyPy' and platform_machine != 's390x' and platform_machine != 'aarch64'
 
 # wheels are missing some EoL interpreters and non-x86 platforms; build would fail unless rust is available
 orjson; platform_python_implementation != 'PyPy' and platform_machine != 's390x' and platform_machine != 'aarch64'

test_patch: diff --git a/tests/_wsgi_test_app.py b/tests/_wsgi_test_app.py
index aa565ef6..f7eb6e9f 100644
--- a/tests/_wsgi_test_app.py
+++ b/tests/_wsgi_test_app.py
@@ -41,7 +41,9 @@ class Hello:
         resp.set_header('X-Falcon', 'deprecated')
 
         resp.content_type = falcon.MEDIA_TEXT
-        resp.body = 'Hello, World!\n'
+        resp.text = 'Hello, World!\n'
+
+        resp.add_link('/removed-methods/add-link', 'bookmark')
 
 
 app = application = falcon.App()
diff --git a/tests/asgi/_asgi_test_app.py b/tests/asgi/_asgi_test_app.py
index ac15a04e..fcebd9bd 100644
--- a/tests/asgi/_asgi_test_app.py
+++ b/tests/asgi/_asgi_test_app.py
@@ -264,6 +264,29 @@ class TestJar:
             resp.status = falcon.HTTP_403
 
 
+class WSOptions:
+    _SUPPORTED_KEYS = frozenset(
+        {'default_close_reasons', 'error_close_code', 'max_receive_queue'}
+    )
+
+    def __init__(self, ws_options):
+        self._ws_options = ws_options
+
+    async def on_get(self, req, resp):
+        resp.media = {
+            key: getattr(self._ws_options, key) for key in self._SUPPORTED_KEYS
+        }
+
+    async def on_patch(self, req, resp):
+        update = await req.get_media()
+        for key, value in update.items():
+            if key not in self._SUPPORTED_KEYS:
+                raise falcon.HTTPInvalidParam('unsupported option', key)
+            setattr(self._ws_options, key, value)
+
+        resp.status = falcon.HTTP_NO_CONTENT
+
+
 def create_app():
     app = falcon.asgi.App()
     bucket = Bucket()
@@ -276,6 +299,7 @@ def create_app():
     app.add_route('/forms', Multipart())
     app.add_route('/jars', TestJar())
     app.add_route('/feeds/{feed_id}', Feed())
+    app.add_route('/wsoptions', WSOptions(app.ws_options))
 
     app.add_middleware(lifespan_handler)
 
diff --git a/tests/asgi/test_asgi_servers.py b/tests/asgi/test_asgi_servers.py
index bda3e28a..eb35ac62 100644
--- a/tests/asgi/test_asgi_servers.py
+++ b/tests/asgi/test_asgi_servers.py
@@ -208,7 +208,20 @@ class TestASGIServer:
 class TestWebSocket:
     @pytest.mark.parametrize('explicit_close', [True, False])
     @pytest.mark.parametrize('close_code', [None, 4321])
-    async def test_hello(self, explicit_close, close_code, server_url_events_ws):
+    @pytest.mark.parametrize('max_receive_queue', [0, 4, 17])
+    async def test_hello(
+        self,
+        explicit_close,
+        close_code,
+        max_receive_queue,
+        server_base_url,
+        server_url_events_ws,
+    ):
+        resp = requests.patch(
+            server_base_url + 'wsoptions', json={'max_receive_queue': max_receive_queue}
+        )
+        resp.raise_for_status()
+
         echo_expected = 'Check 1 - \U0001f600'
 
         extra_headers = {'X-Command': 'recv'}
@@ -237,9 +250,9 @@ class TestWebSocket:
                     message_binary = await ws.recv()
                 except websockets.exceptions.ConnectionClosed as ex:
                     if explicit_close and close_code:
-                        assert ex.code == close_code
+                        assert ex.rcvd.code == close_code
                     else:
-                        assert ex.code == 1000
+                        assert ex.rcvd.code == 1000
 
                     break
 
diff --git a/tests/asgi/test_response_media_asgi.py b/tests/asgi/test_response_media_asgi.py
index a0a64f9d..65453629 100644
--- a/tests/asgi/test_response_media_asgi.py
+++ b/tests/asgi/test_response_media_asgi.py
@@ -7,7 +7,6 @@ from falcon import errors
 from falcon import media
 from falcon import testing
 import falcon.asgi
-from falcon.util.deprecation import DeprecatedWarning
 
 try:
     import msgpack
@@ -182,8 +181,7 @@ def test_default_media_type():
     assert result.json == doc
 
 
-@pytest.mark.parametrize('monkeypatch_resolver', [True, False])
-def test_mimeparse_edgecases(monkeypatch_resolver):
+def test_mimeparse_edgecases():
     doc = {'something': True}
 
     class TestResource:
@@ -205,21 +203,6 @@ def test_mimeparse_edgecases(monkeypatch_resolver):
 
     client = create_client(TestResource())
 
-    handlers = client.app.resp_options.media_handlers
-
-    # NOTE(kgriffs): Test the pre-3.0 method. Although undocumented, it was
-    #   technically a public method, and so we make sure it still works here.
-    if monkeypatch_resolver:
-
-        def _resolve(media_type, default, raise_not_found=True):
-            with pytest.warns(DeprecatedWarning, match='This undocumented method'):
-                h = handlers.find_by_media_type(
-                    media_type, default, raise_not_found=raise_not_found
-                )
-            return h, None, None
-
-        handlers._resolve = _resolve
-
     result = client.simulate_get('/')
     assert result.json == doc
 
diff --git a/tests/asgi/test_ws.py b/tests/asgi/test_ws.py
index c4d2a7d1..dc068c7f 100644
--- a/tests/asgi/test_ws.py
+++ b/tests/asgi/test_ws.py
@@ -1,5 +1,6 @@
 import asyncio
 from collections import deque
+import functools
 import os
 
 import pytest
@@ -948,36 +949,77 @@ async def test_translate_webserver_error(conductor):
         async def on_websocket(self, req, ws):
             await ws.accept()
 
-            async def raise_disconnect(self):
+            async def raise_disconnect(event):
                 raise Exception('Disconnected with code = 1000 (OK)')
 
-            async def raise_protocol_mismatch(self):
+            async def raise_io_error(event, cause=''):
+                class ConnectionClosed(RuntimeError):
+                    pass
+
+                class ClientDisconnected(OSError):
+                    pass
+
+                if cause:
+                    raise ClientDisconnected() from ConnectionClosed(cause)
+                raise ClientDisconnected()
+
+            async def raise_protocol_mismatch(event):
                 raise Exception('protocol accepted must be from the list')
 
-            async def raise_other(self):
+            async def raise_other(event):
                 raise RuntimeError()
 
+            # TODO(vytas): It would be nice to somehow refactor this test not
+            #   to operate on the private members of WebSocket.
+            #   But, OTOH, it is quite useful as it is.
             _asgi_send = ws._asgi_send
+            _original_state = ws._state
 
             ws._asgi_send = raise_other
+            ws._state = _original_state
             try:
                 await ws.send_data(b'123')
             except Exception:
                 self.error_count += 1
 
             ws._asgi_send = raise_protocol_mismatch
+            ws._state = _original_state
             try:
                 await ws.send_data(b'123')
             except ValueError:
                 self.error_count += 1
 
             ws._asgi_send = raise_disconnect
+            ws._state = _original_state
+            try:
+                await ws.send_data(b'123')
+            except falcon.WebSocketDisconnected:
+                self.error_count += 1
+
+            ws._asgi_send = raise_io_error
+            ws._state = _original_state
             try:
                 await ws.send_data(b'123')
             except falcon.WebSocketDisconnected:
                 self.error_count += 1
 
+            ws._asgi_send = functools.partial(raise_io_error, cause='bork3d pipe')
+            ws._state = _original_state
+            try:
+                await ws.send_data(b'123')
+            except falcon.WebSocketDisconnected:
+                self.error_count += 1
+
+            ws._asgi_send = functools.partial(raise_io_error, cause='received 1001')
+            ws._state = _original_state
+            try:
+                await ws.send_data(b'123')
+            except falcon.WebSocketDisconnected:
+                self.error_count += 1
+            assert ws._close_code == 1001
+
             ws._asgi_send = _asgi_send
+            ws._state = _original_state
 
             self.test_complete.set()
 
@@ -988,6 +1030,8 @@ async def test_translate_webserver_error(conductor):
         async with c.simulate_ws():
             await resource.test_complete.wait()
 
+    assert resource.error_count == 6
+
 
 def test_ws_base_not_implemented():
     th = media.TextBaseHandlerWS()
@@ -1062,10 +1106,11 @@ async def test_ws_simulator_collect_edge_cases(conductor):
 
         await ws.close()
 
-        # NOTE(kgriffs): The collector should just eat all subsequent events
-        #   returned from the ASGI app.
-        for __ in range(100):
-            await ws._collect({'type': EventType.WS_SEND})
+        # NOTE(vytas): The collector should start raising an instance of
+        #   OSError from now on per the ASGI WS spec ver 2.4+.
+        for __ in range(10):
+            with pytest.raises(OSError):
+                await ws._collect({'type': EventType.WS_SEND})
 
         assert not ws._collected_server_events
 
@@ -1372,3 +1417,43 @@ async def test_ws_http_error_or_status_error_handler(
                 async with c.simulate_ws():
                     pass
             assert err.value.code == exp_code
+
+
+@pytest.mark.parametrize('max_receive_queue', [0, 1, 4, 7, 17])
+async def test_max_receive_queue_sizes(conductor, max_receive_queue):
+    class Chat:
+        async def on_websocket(self, req, ws, user):
+            await ws.accept()
+
+            broadcast = [
+                '[Guest123] ping',
+                '[John F. Barbaz] Hi everyone.',
+                f'Hello, {user}!',
+            ]
+
+            while True:
+                await ws.send_text(broadcast.pop())
+
+                msg = await ws.receive_text()
+                if msg == '/quit':
+                    await ws.send_text('Bye!')
+                    await ws.close(reason='Received /quit')
+                    break
+                else:
+                    await ws.send_text(f'[{user}] {msg}')
+
+    conductor.app.ws_options.max_receive_queue = max_receive_queue
+    conductor.app.add_route('/chat/{user}', Chat())
+
+    received = []
+    messages = ['/quit', 'I have to leave this test soon.', 'Hello!']
+
+    async with conductor as c:
+        async with c.simulate_ws('/chat/foobarer') as ws:
+            while messages:
+                received.append(await ws.receive_text())
+
+                await ws.send_text(messages.pop())
+                received.append(await ws.receive_text())
+
+    assert len(received) == 6
diff --git a/tests/test_alias.py b/tests/test_alias.py
index ffb1df18..d38d3d78 100644
--- a/tests/test_alias.py
+++ b/tests/test_alias.py
@@ -15,7 +15,7 @@ class CookieResource:
 
 @pytest.fixture
 def alias_client():
-    with pytest.warns(DeprecatedWarning, match='API class may be removed'):
+    with pytest.warns(DeprecatedWarning, match='API class will be removed'):
         api = falcon.API()
     api.add_route('/get-cookie', CookieResource())
     return testing.TestClient(api)
@@ -42,6 +42,6 @@ def test_cookies(alias_client, app_client):
 
 
 def test_alias_equals_to_app(alias_client):
-    with pytest.warns(DeprecatedWarning, match='API class may be removed'):
+    with pytest.warns(DeprecatedWarning, match='API class will be removed'):
         api = falcon.API()
     assert isinstance(api, falcon.API)
diff --git a/tests/test_cmd_inspect_app.py b/tests/test_cmd_inspect_app.py
index 83784952..0856df44 100644
--- a/tests/test_cmd_inspect_app.py
+++ b/tests/test_cmd_inspect_app.py
@@ -198,8 +198,9 @@ def test_route_main(monkeypatch):
 
     monkeypatch.setattr(inspect_app, 'main', mock)
     output = io.StringIO()
-    with redirected(stdout=output):
-        inspect_app.route_main()
+    with redirected(stderr=output):
+        with pytest.raises(SystemExit):
+            inspect_app.route_main()
 
-    assert 'deprecated' in output.getvalue()
-    assert called
+    assert 'no longer supported' in output.getvalue()
+    assert not called
diff --git a/tests/test_error.py b/tests/test_error.py
index a3665f66..f1e2e40c 100644
--- a/tests/test_error.py
+++ b/tests/test_error.py
@@ -3,7 +3,6 @@ import pytest
 import falcon
 import falcon.errors as errors
 import falcon.status_codes as status
-from falcon.util.deprecation import DeprecatedWarning
 
 
 @pytest.mark.parametrize(
@@ -151,10 +150,7 @@ def test_with_title_desc_and_headers(err):
     ],
 )
 def test_kw_only(err):
-    # only deprecated for now
-    # with pytest.raises(TypeError, match='positional argument'):
-    #     err('foo', 'bar')
-    with pytest.warns(DeprecatedWarning, match='positional args are deprecated'):
+    with pytest.raises(TypeError, match='positional argument'):
         err('foo', 'bar')
 
 
@@ -190,10 +186,7 @@ def test_with_title_desc_and_headers_args(err, args):
     ),
 )
 def test_args_kw_only(err, args):
-    # only deprecated for now
-    # with pytest.raises(TypeError, match='positional argument'):
-    #     err(*args, 'bar')
-    with pytest.warns(DeprecatedWarning, match='positional args are deprecated'):
+    with pytest.raises(TypeError, match='positional argument'):
         err(*args, 'bar')
 
 
diff --git a/tests/test_headers.py b/tests/test_headers.py
index 67a80e14..871e0896 100644
--- a/tests/test_headers.py
+++ b/tests/test_headers.py
@@ -56,7 +56,8 @@ class HeaderHelpersResource:
         resp.last_modified = self.last_modified
         resp.retry_after = 3601
 
-        # Relative URI's are OK per http://goo.gl/DbVqR
+        # Relative URI's are OK per
+        # https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.2
         resp.location = '/things/87'
         resp.content_location = '/things/78'
 
@@ -866,12 +867,12 @@ class TestHeaders:
         uri = 'ab\u00e7'
 
         resource = LinkHeaderResource()
-        resource.add_link('/things/2842', 'next')
+        resource.append_link('/things/2842', 'next')
         resource.append_link('http://\u00e7runchy/bacon', 'contents')
         resource.append_link(uri, 'http://example.com/ext-type')
-        resource.add_link(uri, 'http://example.com/\u00e7runchy')
+        resource.append_link(uri, 'http://example.com/\u00e7runchy')
         resource.append_link(uri, 'https://example.com/too-\u00e7runchy')
-        resource.add_link('/alt-thing', 'alternate http://example.com/\u00e7runchy')
+        resource.append_link('/alt-thing', 'alternate http://example.com/\u00e7runchy')
 
         self._check_link_header(client, resource, expected_value)
 
diff --git a/tests/test_httperror.py b/tests/test_httperror.py
index a3d68724..745286b4 100644
--- a/tests/test_httperror.py
+++ b/tests/test_httperror.py
@@ -8,7 +8,6 @@ import pytest
 
 import falcon
 import falcon.testing as testing
-from falcon.util.deprecation import DeprecatedWarning
 
 try:
     import yaml
@@ -946,8 +945,5 @@ class TestHTTPError:
 
 
 def test_kw_only():
-    # only deprecated for now
-    # with pytest.raises(TypeError, match='positional argument'):
-    #     falcon.HTTPError(falcon.HTTP_BAD_REQUEST, 'foo', 'bar')
-    with pytest.warns(DeprecatedWarning, match='positional args are deprecated'):
+    with pytest.raises(TypeError, match='positional argument'):
         falcon.HTTPError(falcon.HTTP_BAD_REQUEST, 'foo', 'bar')
diff --git a/tests/test_httpstatus.py b/tests/test_httpstatus.py
index ed17d009..4bbaec36 100644
--- a/tests/test_httpstatus.py
+++ b/tests/test_httpstatus.py
@@ -5,7 +5,6 @@ import pytest
 import falcon
 from falcon.http_status import HTTPStatus
 import falcon.testing as testing
-from falcon.util.deprecation import AttributeRemovedError
 
 
 @pytest.fixture()
@@ -277,6 +276,3 @@ def test_deprecated_body():
 
     sts = HTTPStatus(falcon.HTTP_701, text='foo')
     assert sts.text == 'foo'
-
-    with pytest.raises(AttributeRemovedError):
-        assert sts.body == 'foo'
diff --git a/tests/test_media_handlers.py b/tests/test_media_handlers.py
index e008248f..02005530 100644
--- a/tests/test_media_handlers.py
+++ b/tests/test_media_handlers.py
@@ -9,7 +9,6 @@ import falcon
 from falcon import media
 from falcon import testing
 from falcon.asgi.stream import BoundedStream
-from falcon.util.deprecation import DeprecatedWarning
 
 mujson = None
 orjson = None
@@ -202,7 +201,6 @@ def test_full_app(asgi, util, dumps, loads, subclass):
     assert res.json == data
 
 
-@pytest.mark.parametrize('monkeypatch_resolver', [True, False])
 @pytest.mark.parametrize(
     'handler_mt',
     [
@@ -211,7 +209,7 @@ def test_full_app(asgi, util, dumps, loads, subclass):
         'application/json; answer=42',
     ],
 )
-def test_deserialization_raises(asgi, util, handler_mt, monkeypatch_resolver):
+def test_deserialization_raises(asgi, util, handler_mt):
     app = util.create_app(asgi)
 
     class SuchException(Exception):
@@ -229,19 +227,6 @@ def test_deserialization_raises(asgi, util, handler_mt, monkeypatch_resolver):
 
     handlers = media.Handlers({handler_mt: FaultyHandler()})
 
-    # NOTE(kgriffs): Test the pre-3.0 method. Although undocumented, it was
-    #   technically a public method, and so we make sure it still works here.
-    if monkeypatch_resolver:
-
-        def _resolve(media_type, default, raise_not_found=True):
-            with pytest.warns(DeprecatedWarning, match='This undocumented method'):
-                h = handlers.find_by_media_type(
-                    media_type, default, raise_not_found=raise_not_found
-                )
-            return h, None, None
-
-        handlers._resolve = _resolve
-
     app.req_options.media_handlers = handlers
     app.resp_options.media_handlers = handlers
 
@@ -368,25 +353,11 @@ def test_async_handler_returning_none(util):
     assert result.json == [None]
 
 
-@pytest.mark.parametrize('monkeypatch_resolver', [True, False])
-def test_json_err_no_handler(asgi, util, monkeypatch_resolver):
+def test_json_err_no_handler(asgi, util):
     app = util.create_app(asgi)
 
     handlers = media.Handlers({falcon.MEDIA_URLENCODED: media.URLEncodedFormHandler()})
 
-    # NOTE(kgriffs): Test the pre-3.0 method. Although undocumented, it was
-    #   technically a public method, and so we make sure it still works here.
-    if monkeypatch_resolver:
-
-        def _resolve(media_type, default, raise_not_found=True):
-            with pytest.warns(DeprecatedWarning, match='This undocumented method'):
-                h = handlers.find_by_media_type(
-                    media_type, default, raise_not_found=raise_not_found
-                )
-            return h, None, None
-
-        handlers._resolve = _resolve
-
     app.req_options.media_handlers = handlers
     app.resp_options.media_handlers = handlers
 
diff --git a/tests/test_options.py b/tests/test_options.py
index 2bb48c10..dcd32cf9 100644
--- a/tests/test_options.py
+++ b/tests/test_options.py
@@ -1,6 +1,7 @@
 import pytest
 
 from falcon.request import RequestOptions
+from falcon.util import deprecation
 
 
 class TestRequestOptions:
@@ -24,7 +25,11 @@ class TestRequestOptions:
     def test_options_toggle(self, option_name):
         options = RequestOptions()
 
-        setattr(options, option_name, True)
+        if option_name == 'auto_parse_form_urlencoded':
+            with pytest.warns(deprecation.DeprecatedWarning):
+                setattr(options, option_name, True)
+        else:
+            setattr(options, option_name, True)
         assert getattr(options, option_name)
 
         setattr(options, option_name, False)
diff --git a/tests/test_query_params.py b/tests/test_query_params.py
index 414a5866..93acbddf 100644
--- a/tests/test_query_params.py
+++ b/tests/test_query_params.py
@@ -8,6 +8,7 @@ import pytest
 import falcon
 from falcon.errors import HTTPInvalidParam
 import falcon.testing as testing
+from falcon.util import deprecation
 
 
 class Resource(testing.SimpleTestResource):
@@ -46,7 +47,8 @@ def resource():
 def client(asgi, util):
     app = util.create_app(asgi)
     if not asgi:
-        app.req_options.auto_parse_form_urlencoded = True
+        with pytest.warns(deprecation.DeprecatedWarning):
+            app.req_options.auto_parse_form_urlencoded = True
 
     return testing.TestClient(app)
 
@@ -1019,7 +1021,8 @@ class TestPostQueryParams:
     def test_asgi_raises_error(self, util, resource):
         app = util.create_app(asgi=True)
         app.add_route('/', resource)
-        app.req_options.auto_parse_form_urlencoded = True
+        with pytest.warns(deprecation.DeprecatedWarning):
+            app.req_options.auto_parse_form_urlencoded = True
 
         with pytest.raises(RuntimeError) as exc_info:
             testing.simulate_get(app, '/')
diff --git a/tests/test_response.py b/tests/test_response.py
index 7fac6b00..d986c7af 100644
--- a/tests/test_response.py
+++ b/tests/test_response.py
@@ -5,6 +5,7 @@ import pytest
 
 from falcon import MEDIA_TEXT
 from falcon import ResponseOptions
+from falcon.util.deprecation import AttributeRemovedError
 
 
 @pytest.fixture()
@@ -39,6 +40,13 @@ def test_response_get_headers(resp):
     assert 'set-cookie' not in headers
 
 
+def test_add_link_removed(resp):
+    # NOTE(kgriffs): Ensure AttributeRemovedError inherits from AttributeError
+    for exc_type in (AttributeError, AttributeRemovedError):
+        with pytest.raises(exc_type):
+            resp.add_link('/things/1337', 'next')
+
+
 def test_response_attempt_to_set_read_only_headers(resp):
     resp.append_header('x-things1', 'thing-1')
     resp.append_header('x-things2', 'thing-2')
diff --git a/tests/test_response_body.py b/tests/test_response_body.py
index 2326389b..7a6b41f6 100644
--- a/tests/test_response_body.py
+++ b/tests/test_response_body.py
@@ -2,7 +2,6 @@ import pytest
 
 import falcon
 from falcon import testing
-from falcon.util.deprecation import AttributeRemovedError
 
 
 @pytest.fixture
@@ -14,20 +13,12 @@ def test_append_body(resp):
     text = 'Hello beautiful world! '
     resp.text = ''
 
-    with pytest.raises(AttributeRemovedError):
-        resp.body = 'x'
-
     for token in text.split():
         resp.text += token
         resp.text += ' '
 
     assert resp.text == text
 
-    # NOTE(kgriffs): Ensure AttributeRemovedError inherits from AttributeError
-    for ErrorType in (AttributeError, AttributeRemovedError):
-        with pytest.raises(ErrorType):
-            resp.body
-
 
 def test_response_repr(resp):
     _repr = '<%s: %s>' % (resp.__class__.__name__, resp.status)
diff --git a/tests/test_uri_templates_legacy.py b/tests/test_uri_templates_legacy.py
deleted file mode 100644
index e7a19eac..00000000
--- a/tests/test_uri_templates_legacy.py
+++ /dev/null
@@ -1,118 +0,0 @@
-import pytest
-
-import falcon
-from falcon import routing
-from falcon.util.deprecation import DeprecatedWarning
-
-
-@pytest.mark.filterwarnings('ignore:Call to deprecated function compile_uri_template')
-class TestUriTemplates:
-    @pytest.mark.parametrize('value', (42, falcon.App))
-    def test_string_type_required(self, value):
-        with pytest.raises(TypeError):
-            routing.compile_uri_template(value)
-
-    @pytest.mark.parametrize('value', ('this', 'this/that'))
-    def test_template_must_start_with_slash(self, value):
-        with pytest.raises(ValueError):
-            routing.compile_uri_template(value)
-
-    @pytest.mark.parametrize('value', ('//', 'a//', '//b', 'a//b', 'a/b//', 'a/b//c'))
-    def test_template_may_not_contain_double_slash(self, value):
-        with pytest.raises(ValueError):
-            routing.compile_uri_template(value)
-
-    def test_root(self):
-        fields, pattern = routing.compile_uri_template('/')
-        assert not fields
-        assert not pattern.match('/x')
-
-        result = pattern.match('/')
-        assert result
-        assert not result.groupdict()
-
-    @pytest.mark.parametrize(
-        'path', ('/hello', '/hello/world', '/hi/there/how/are/you')
-    )
-    def test_no_fields(self, path):
-        fields, pattern = routing.compile_uri_template(path)
-        assert not fields
-        assert not pattern.match(path[:-1])
-
-        result = pattern.match(path)
-        assert result
-        assert not result.groupdict()
-
-    def test_one_field(self):
-        fields, pattern = routing.compile_uri_template('/{name}')
-        assert fields == {'name'}
-
-        result = pattern.match('/Kelsier')
-        assert result
-        assert result.groupdict() == {'name': 'Kelsier'}
-
-        fields, pattern = routing.compile_uri_template('/character/{name}')
-        assert fields == {'name'}
-
-        result = pattern.match('/character/Kelsier')
-        assert result
-        assert result.groupdict() == {'name': 'Kelsier'}
-
-        fields, pattern = routing.compile_uri_template('/character/{name}/profile')
-        assert fields == {'name'}
-
-        assert not pattern.match('/character')
-        assert not pattern.match('/character/Kelsier')
-        assert not pattern.match('/character/Kelsier/')
-
-        result = pattern.match('/character/Kelsier/profile')
-        assert result
-        assert result.groupdict() == {'name': 'Kelsier'}
-
-    def test_one_field_with_digits(self):
-        fields, pattern = routing.compile_uri_template('/{name123}')
-        assert fields == {'name123'}
-
-        result = pattern.match('/Kelsier')
-        assert result
-        assert result.groupdict() == {'name123': 'Kelsier'}
-
-    def test_one_field_with_prefixed_digits(self):
-        fields, pattern = routing.compile_uri_template('/{37signals}')
-        assert fields == set()
-
-        result = pattern.match('/s2n')
-        assert not result
-
-    @pytest.mark.parametrize('postfix', ('', '/'))
-    def test_two_fields(self, postfix):
-        path = '/book/{book_id}/characters/{n4m3}' + postfix
-        fields, pattern = routing.compile_uri_template(path)
-        assert fields == {'n4m3', 'book_id'}
-
-        result = pattern.match('/book/0765350386/characters/Vin')
-        assert result
-        assert result.groupdict() == {'n4m3': 'Vin', 'book_id': '0765350386'}
-
-    def test_three_fields(self):
-        fields, pattern = routing.compile_uri_template('/{a}/{b}/x/{c}')
-        assert fields == set('abc')
-
-        result = pattern.match('/one/2/x/3')
-        assert result
-        assert result.groupdict() == {'a': 'one', 'b': '2', 'c': '3'}
-
-    def test_malformed_field(self):
-        fields, pattern = routing.compile_uri_template('/{a}/{1b}/x/{c}')
-        assert fields == set('ac')
-
-        result = pattern.match('/one/{1b}/x/3')
-        assert result
-        assert result.groupdict() == {'a': 'one', 'c': '3'}
-
-    def test_deprecated_warning(self):
-        with pytest.warns(
-            DeprecatedWarning,
-            match='Call to deprecated function compile_uri_template().',
-        ):
-            routing.compile_uri_template('/')
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 159fdd9a..14da664f 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -493,34 +493,6 @@ class TestFalconUtils:
         assert uri.parse_host('falcon.example.com:9876') == ('falcon.example.com', 9876)
         assert uri.parse_host('falcon.example.com:42') == ('falcon.example.com', 42)
 
-    def test_get_http_status_warns(self):
-        with pytest.warns(UserWarning, match='Please use falcon'):
-            falcon.get_http_status(400)
-
-    @pytest.mark.filterwarnings('ignore')
-    def test_get_http_status(self):
-        assert falcon.get_http_status(404) == falcon.HTTP_404
-        assert falcon.get_http_status(404.3) == falcon.HTTP_404
-        assert falcon.get_http_status('404.3') == falcon.HTTP_404
-        assert falcon.get_http_status(404.9) == falcon.HTTP_404
-        assert falcon.get_http_status('404') == falcon.HTTP_404
-        assert falcon.get_http_status(123) == '123 Unknown'
-        with pytest.raises(ValueError):
-            falcon.get_http_status('not_a_number')
-        with pytest.raises(ValueError):
-            falcon.get_http_status(0)
-        with pytest.raises(ValueError):
-            falcon.get_http_status(0)
-        with pytest.raises(ValueError):
-            falcon.get_http_status(99)
-        with pytest.raises(ValueError):
-            falcon.get_http_status(-404.3)
-        with pytest.raises(ValueError):
-            falcon.get_http_status('-404')
-        with pytest.raises(ValueError):
-            falcon.get_http_status('-404.3')
-        assert falcon.get_http_status(123, 'Go Away') == '123 Go Away'
-
     @pytest.mark.parametrize(
         'v_in,v_out',
         [
@@ -1214,7 +1186,7 @@ class TestNoApiClass(testing.TestCase):
 class TestSetupApi(testing.TestCase):
     def setUp(self):
         super(TestSetupApi, self).setUp()
-        with pytest.warns(UserWarning, match='API class may be removed in a future'):
+        with pytest.warns(UserWarning, match='API class will be removed in Falcon 5.0'):
             self.app = falcon.API()
         self.app.add_route('/', testing.SimpleTestResource(body='test'))
 
@@ -1419,14 +1391,6 @@ class TestDeprecatedArgs:
         assert 'a_function(...)' in str(recwarn[0].message)
 
 
-def test_json_deprecation():
-    with pytest.warns(deprecation.DeprecatedWarning, match='json'):
-        falcon.util.json
-
-    with pytest.raises(AttributeError):
-        falcon.util.some_imaginary_module
-
-
 def test_TimezoneGMT():
     with pytest.warns(deprecation.DeprecatedWarning):
         tz = TimezoneGMT()
diff --git a/tests/test_wsgi_servers.py b/tests/test_wsgi_servers.py
index e8f00892..e9f4520c 100644
--- a/tests/test_wsgi_servers.py
+++ b/tests/test_wsgi_servers.py
@@ -208,7 +208,7 @@ class TestWSGIServer:
     def test_get_deprecated(self, server_url):
         resp = requests.get(server_url + '/deprecated', timeout=_REQUEST_TIMEOUT)
 
-        # Since it tries to set .body we expect an unhandled error
+        # Since it tries to use resp.add_link() we expect an unhandled error
         assert resp.status_code == 500
 
     def test_post_multipart_form(self, server_url):


----

problem_statement: Add flag to disable XML serialization; deprecate `HTTPError.to_xml()`
This serialization is kinda it's own thing that's different from everything else falcon behaves wrt media serialization.

Also in 2024 it's unlikely that people writing python application will ever touch xml

date: 2024-10-05
patch: diff --git a/.coveragerc b/.coveragerc
index 0b87d961..64399d91 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,7 +1,7 @@
 [run]
 branch = True
 source = falcon
-omit = falcon/tests*,falcon/typing.py,falcon/cmd/bench.py,falcon/bench/*,falcon/vendor/*
+omit = falcon/tests*,falcon/_typing.py,falcon/cmd/bench.py,falcon/bench/*,falcon/vendor/*
 
 parallel = True
 
diff --git a/.github/workflows/cibuildwheel.yaml b/.github/workflows/cibuildwheel.yaml
index c62b840a..b0337d80 100644
--- a/.github/workflows/cibuildwheel.yaml
+++ b/.github/workflows/cibuildwheel.yaml
@@ -112,7 +112,7 @@ jobs:
           platforms: all
 
       - name: Build wheels
-        uses: pypa/cibuildwheel@v2.20.0
+        uses: pypa/cibuildwheel@v2.21.1
         env:
           CIBW_ARCHS_LINUX: all
           CIBW_BUILD: ${{ matrix.python }}-${{ matrix.platform.name }}
diff --git a/.github/workflows/tests-mailman.yaml b/.github/workflows/test-mailman.yaml
similarity index 92%
rename from .github/workflows/tests-mailman.yaml
rename to .github/workflows/test-mailman.yaml
index ccb6a00e..ab69fedd 100644
--- a/.github/workflows/tests-mailman.yaml
+++ b/.github/workflows/test-mailman.yaml
@@ -1,4 +1,5 @@
-name: Run tests (GNU Mailman 3)
+# Test that the current dev branch does not break GNU Mailman 3.
+name: test-mailman
 
 on:
   # Trigger the workflow on master but also allow it to run manually.
diff --git a/.github/workflows/test-other.yaml b/.github/workflows/test-other.yaml
new file mode 100644
index 00000000..4e828926
--- /dev/null
+++ b/.github/workflows/test-other.yaml
@@ -0,0 +1,48 @@
+# Other tests, primarily those requiring obsolete dependencies.
+name: test-other
+
+on:
+  push:
+    branches:
+      - "*"
+
+jobs:
+  run-tox:
+    name: tox -e ${{ matrix.toxenv }}
+    runs-on: ubuntu-20.04
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version:
+          - "3.8"
+        toxenv:
+          - "py38_smoke"
+          - "py38_smoke_cython"
+          - "wsgi_meinheld"
+
+    steps:
+      - name: Checkout repo
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 2
+
+      - name: Set up Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Install smoke test dependencies
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y libunwind-dev
+
+      - name: Install Python dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install --upgrade setuptools tox wheel
+          python --version
+          pip --version
+          tox --version
+
+      - name: Run tox
+        run: tox -e ${{ matrix.toxenv }}
diff --git a/.github/workflows/test-wheels.yaml b/.github/workflows/test-wheels.yaml
new file mode 100644
index 00000000..43c7644a
--- /dev/null
+++ b/.github/workflows/test-wheels.yaml
@@ -0,0 +1,50 @@
+# Test various architectures by building out binaries with cibuildwheel.
+name: test-wheels
+
+on:
+  # Trigger the workflow on master but also allow it to run manually.
+  workflow_dispatch:
+  push:
+    branches:
+      - master
+
+jobs:
+  test-emulated:
+    name: "cibuildwheel: ${{ matrix.platform.build }}"
+    runs-on: ${{ matrix.platform.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        platform:
+          - build: "cp313-manylinux_aarch64"
+            os: ubuntu-latest
+            emulation: true
+          - build: "cp313-manylinux_s390x"
+            os: ubuntu-latest
+            emulation: true
+          - build: "cp38-manylinux_x86_64"
+            os: ubuntu-latest
+          - build: "cp313-musllinux_x86_64"
+            os: ubuntu-latest
+          - build: "cp313-macosx_arm64"
+            os: macos-14
+          - build: "cp313-win_amd64"
+            os: windows-latest
+
+    steps:
+      - name: Checkout repo
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 2
+
+      - name: Set up QEMU
+        uses: docker/setup-qemu-action@v3
+        if: ${{ matrix.platform.emulation }}
+        with:
+          platforms: all
+
+      - name: Build wheels
+        uses: pypa/cibuildwheel@v2.21.1
+        env:
+          CIBW_ARCHS_LINUX: all
+          CIBW_BUILD: ${{ matrix.platform.build }}
diff --git a/.github/workflows/tests-emulated.yaml b/.github/workflows/tests-emulated.yaml
deleted file mode 100644
index 5cae65c2..00000000
--- a/.github/workflows/tests-emulated.yaml
+++ /dev/null
@@ -1,36 +0,0 @@
-name: Run tests (emulated)
-
-on:
-  # Trigger the workflow on master but also allow it to run manually.
-  workflow_dispatch:
-  push:
-    branches:
-      - master
-
-jobs:
-  test-emulated:
-    name: "cibuildwheel: ${{ matrix.platform }}"
-    runs-on: ubuntu-latest
-    strategy:
-      fail-fast: false
-      matrix:
-        platform:
-          - "cp312-manylinux_aarch64"
-          - "cp312-manylinux_s390x"
-
-    steps:
-      - name: Checkout repo
-        uses: actions/checkout@v4
-        with:
-          fetch-depth: 2
-
-      - name: Set up QEMU
-        uses: docker/setup-qemu-action@v3
-        with:
-          platforms: all
-
-      - name: Build wheels
-        uses: pypa/cibuildwheel@v2.20.0
-        env:
-          CIBW_ARCHS_LINUX: all
-          CIBW_BUILD: ${{ matrix.platform }}
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index 078e5f59..47ece6be 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -1,4 +1,5 @@
-name: Run tests
+# Run tests in various tox environments.
+name: tests
 
 on:
   # NOTE(vytas): Trigger the tests workflow on push or pull request
@@ -11,104 +12,73 @@ on:
       - master
 
 jobs:
-  run_tox:
-    name: tox -e ${{ matrix.toxenv }} (${{matrix.python-version}} on ${{ matrix.os }})
-    runs-on: ${{ matrix.os }}
+  run-tox:
+    name: tox -e ${{ matrix.tox.env }}${{ matrix.tox.platform-label || '' }}
+    runs-on: ${{ matrix.tox.os || matrix.default-os }}
     strategy:
       fail-fast: false
       matrix:
+        default-os:
+          - "ubuntu-latest"
         python-version:
           - "3.12"
-        python-dev-version:
-          - ""
-        os:
-          - "ubuntu-latest"
-        toxenv:
-          - "pep8"
-          - "pep8-docstrings"
-          - "pep8-examples"
-          - "ruff"
-          - "mypy"
-          - "mypy_tests"
-          - "py312"
-          - "py312_sans_msgpack"
-          - "py312_cython"
-          - "docs"
-          - "towncrier"
-          - "look"
-          - "asgilook"
-          - "ws_tutorial"
-          # NOTE(vytas): Temporarily disabled while we are releasing a
-          #   new version of python-mimeparse, and haven't decided yet whether
-          #   to keep vendoring or reimplement.
-          # - "check_vendored"
-          - "twine_check"
-          - "daphne"
-          - "hypercorn"
-          - "e2e_chrome"
-          - "e2e_firefox"
-          - "no_optional_packages"
-          # TODO(kgriffs): Re-enable once hug has a chance to address
-          # breaking changes in Falcon 3.0
-          # - "hug"
-        include:
-          - python-version: pypy3.9
-            os: ubuntu-latest
-            toxenv: pypy3
-          - python-version: "3.8"
-            os: ubuntu-latest
-            toxenv: py38
-          - python-version: "3.9"
-            os: ubuntu-latest
-            toxenv: py39
-          - python-version: "3.9"
-            os: ubuntu-latest
-            toxenv: py39_cython
-          - python-version: "3.10"
-            os: ubuntu-latest
-            toxenv: py310
-          - python-version: "3.10"
-            os: ubuntu-latest
-            toxenv: py310_cython
-          - python-version: "3.11"
-            os: ubuntu-latest
-            toxenv: py311
-          - python-version: "3.11"
-            os: ubuntu-latest
-            toxenv: py311_cython
-          - python-version: "3.12"
-            os: ubuntu-latest
-            toxenv: py312
-          - python-version: "3.12"
-            os: ubuntu-latest
-            toxenv: py312_cython
-          - python-version: "3.12"
+        tox:
+          # Lint
+          - env: pep8
+          - env: pep8-docstrings
+          - env: ruff
+          # Documentation
+          - env: docs
+          - env: towncrier
+          # Typing
+          - env: mypy
+          - env: mypy_tests
+          # Python tests
+          - env: mintest
+            coverage: true
+          - env: pypy3
+            python-version: "pypy3.10"
+          - env: py38
+            python-version: "3.8"
+          - env: py39
+            python-version: "3.9"
+          - env: py310
+            python-version: "3.10"
+          - env: py310_cython
+            python-version: "3.10"
+          - env: py311
+            python-version: "3.11"
+          - env: py311_cython
+            python-version: "3.11"
+          - env: py312
+            coverage: true
+            python-version: "3.12"
+          - env: py312_cython
+            python-version: "3.12"
+          - env: py313
+            python-version: "3.13.0-rc.3 - 3.13"
+          - env: py313_cython
+            python-version: "3.13.0-rc.3 - 3.13"
+          - env: py312_nocover
             os: macos-latest
-            toxenv: py312_nocover
-          - python-version: "3.12"
+            platform-label: ' (macos)'
+          - env: py312_nocover
             os: windows-latest
-            toxenv: py312_nocover
-          - python-version: "3.13"
-            python-dev-version: "3.13.0-rc.1 - 3.13"
-            os: ubuntu-latest
-            toxenv: py313
-          - python-version: "3.13"
-            python-dev-version: "3.13.0-rc.1 - 3.13"
-            os: ubuntu-latest
-            toxenv: py313_cython
-          # These env require 3.8 and 20.04, see tox.ini
-          - python-version: "3.8"
-            os: ubuntu-20.04
-            toxenv: py38_smoke
-          - python-version: "3.8"
-            os: ubuntu-20.04
-            toxenv: py38_smoke_cython
-          - python-version: "3.8"
-            os: ubuntu-latest
-            toxenv: "wsgi_servers"
+            platform-label: ' (windows)'
+          # Tutorials
+          - env: "look"
+          - env: "asgilook"
+          - env: "ws_tutorial"
+          # Tooling
+          - env: "twine_check"
+          # ASGI & WSGI servers
+          - env: "daphne"
+          - env: "hypercorn"
+          - env: "wsgi_servers"
+          # E2E tests
+          - env: "e2e_chrome"
+          - env: "e2e_firefox"
 
-    # Steps to run in each job.
-    # Some are GitHub actions, others run shell commands.
     steps:
       - name: Checkout repo
         uses: actions/checkout@v4
@@ -119,43 +89,30 @@ jobs:
 
       - name: Set up Python
         uses: actions/setup-python@v5
-        if: ${{ !matrix.python-dev-version }}
         with:
-          python-version: ${{ matrix.python-version }}
-
-      - name: Set up Python (pre-release)
-        uses: actions/setup-python@v5
-        if: ${{ matrix.python-dev-version }}
-        with:
-          python-version: ${{ matrix.python-dev-version }}
-
-      - name: Install smoke test dependencies
-        if: ${{ matrix.toxenv == 'py38_smoke' || matrix.toxenv == 'py38_smoke_cython' }}
-        run: |
-          sudo apt-get update
-          sudo apt-get install -y libunwind-dev
+          python-version: ${{ matrix.tox.python-version || matrix.python-version }}
 
-      - name: Install dependencies
+      - name: Install Python dependencies
         run: |
           python -m pip install --upgrade pip
-          pip install -U coverage fixtures setuptools tox wheel
+          pip install --upgrade coverage setuptools tox wheel
           python --version
           pip --version
           tox --version
           coverage --version
 
-      - name: Run tests
-        run: tox -e ${{ matrix.toxenv }}
+      - name: Run tox
+        run: tox -e ${{ matrix.tox.env }}
 
       - name: Combine coverage
-        if: ${{ matrix.toxenv == 'py312' || matrix.toxenv == 'py312_sans_msgpack' }}
+        if: ${{ matrix.tox.coverage }}
         run: |
           coverage --version
           coverage combine
 
       - name: Upload coverage to Codecov
         uses: codecov/codecov-action@v4
-        if: ${{ matrix.toxenv == 'py312' || matrix.toxenv == 'py312_sans_msgpack' }}
+        if: ${{ matrix.tox.coverage }}
         with:
           env_vars: PYTHON
           fail_ci_if_error: true
diff --git a/.github/workflows/mintest.yaml b/.github/workflows/tox-sdist.yaml
similarity index 96%
rename from .github/workflows/mintest.yaml
rename to .github/workflows/tox-sdist.yaml
index c3a6b5f7..0986fc84 100644
--- a/.github/workflows/mintest.yaml
+++ b/.github/workflows/tox-sdist.yaml
@@ -1,4 +1,4 @@
-name: Run tests (contributor's checklist)
+name: test-tox-sdist
 
 on:
   # Trigger the workflow on master but also allow it to run manually.
diff --git a/AUTHORS b/AUTHORS
index 3ec67da8..95b44b1f 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -23,8 +23,10 @@ listed below by date of first contribution:
 * Derrick Wippler (thrawn01)
 * Sebastián Magrí (sebasmagri)
 * Chris Petersen (ex-nerd)
+* Cindy Pallares (cindy)
 * Andy Freeland (rouge8)
 * Michał Jaworski (swistakm)
+* Michele Lacchia (rubik)
 * Jason Campbell (xiaclo)
 * Barry Warsaw (warsaw)
 * Jeffrey Zhang (jeffrey4l)
@@ -34,6 +36,7 @@ listed below by date of first contribution:
 * David Pineau (Joacchim)
 * Benjamen Meyer (BenjamenMeyer)
 * Sriram Madapusi Vasudevan (TheSriram)
+* Omer Katz (thedrow)
 * Erik Erwitt (eerwitt)
 * Bernhard Weitzhofer (b6d)
 * Rahman Syed (rsyed83)
@@ -49,6 +52,8 @@ listed below by date of first contribution:
 * Maxim Tsoy (muodov)
 * Christian Pedersen (chripede)
 * Harrison Pincket (hpincket)
+* Richard Olsson (richardolsson)
+* Zack Gold (zg)
 * Usman Ehtesham Gul (ueg1990)
 * Adam Yala (adamyala)
 * Grigory Bakunov (bobuk)
@@ -70,7 +75,58 @@ listed below by date of first contribution:
 * Yash Mehrotra (yashmehrotra)
 * Stephen Milner (ashcrow)
 * ksonj
+* Christian Jensen (jensenbox)
+* Sam Raker (swizzard)
+* Ali-Akber Saifee (alisaifee)
+* stiefel40k
+* Zhigang Wang (w1z2g3)
+* Rafał Kasa (rafalkasa)
+* Qingping Hou (houqp)
+* wssbck
+* Martin Asell Backlund (masellfoodpanda)
+* Matt Giles (mattgiles)
+* Naveen Yadav (navyad)
+* roeih-stratoscale
+* Jahongir (rahmonov)
+* Hans Lellelid (hozn)
+* Steven Seguin (sseg)
+* Michal Bultrowicz (butla)
+* Joshua Adelman (synapticarbors)
+* Ron Rothman (RonRothman)
+* mayurmahajan
+* Serge (yasek)
+* Munir Contractor (munircontractor)
+* Max (DSIW)
+* Caleb Hayashida (santeyio)
+* Tim Chen (timc13)
+* Griff George (GriffGeorge)
+* Russ (rhemz)
+* Hynek Schlawack (hynek)
+* Tyrone Groves (tyronegroves)
+* Justin Siebert (Just-Sieb)
 * Vytautas Liuolia (vytas7)
+* Hugo van Kemenade  (hugovk)
+* Ryan Turner (ZDBioHazard)
+* Federico Caselli (CaselIT)
+* Steven Jackson (steven-upside)
+* ozzzik
+* elf69 (selfvin)
+* Josh Klar (klardotsh)
+* Carlo (carlodri)
+* Akeem King (KingAkeem)
+* Dmitrii Trofimov (DmitriiTrofimov)
+* Anthony Bush (awbush)
+* bkcsfi
+* Aston Motes (astonm)
+* TheMushr00m (TheMushrr00m)
+* Aly Sivji (alysivji)
+* Anthony (tribals)
+* Alan Parker Lue (aparkerlue)
+* Nate D. (Nateyo)
+* Rajan Paneru (paneru-rajan)
+* Dmitri Vasilishin (dmvass)
+* Andrii Marynets (amarynets)
+* Zubair Akram (zubairakram)
 * Nick Zaccardi (nZac)
 * Safa Ozturk (safaozturk93)
 * Brent Smith (edmondb)
@@ -90,7 +146,6 @@ listed below by date of first contribution:
 * Mostafa Khaki (mosi-kha)
 * nagaabhinaya
 * Matt Donders (mattdonders)
-* Federico Caselli (CaselIT)
 * Yang Keming (kemingy)
 * Tim Gates (timgates42)
 * Paul (hackedd)
@@ -143,6 +198,7 @@ listed below by date of first contribution:
 * Dave Tapley (davetapley)
 * Agustin Arce (aarcex3)
 * Christian Grossmüller (chgad)
+* Sai Prathik R (prathik2401)
 
 (et al.)
 
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5422355c..0307fb19 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -37,6 +37,14 @@ $ pip install -U ruff
 $ ruff format
 ```
 
+You can also reformat your code, and apply safe ``ruff`` fixes, via the
+``reformat`` ``tox`` environment:
+
+```bash
+$ pip install -U tox
+$ tox -e reformat
+```
+
 You can check all this by running ``tox`` from within the Falcon project directory.
 Your environment must be based on CPython 3.10, 3.11, 3.12 or 3.13:
 
diff --git a/README.rst b/README.rst
index bd83f991..2e5d1b3f 100644
--- a/README.rst
+++ b/README.rst
@@ -4,7 +4,7 @@
    :target: https://falconframework.org/
    :width: 100 %
 
-|Build Status| |Docs| |codecov.io|
+|Build status| |Docs| |codecov.io| |PyPI package| |Python versions|
 
 The Falcon Web Framework
 ========================
@@ -278,7 +278,7 @@ available to your app without having to reinstall the package:
 .. code:: bash
 
     $ cd falcon
-    $ pip install -e .
+    $ FALCON_DISABLE_CYTHON=Y pip install -e .
 
 You can manually test changes to the Falcon framework by switching to the
 directory of the cloned repo and then running pytest:
@@ -573,7 +573,7 @@ Note that this example assumes that the
                 msg = ('The size of the request is too large. The body must not '
                        'exceed ' + str(limit) + ' bytes in length.')
 
-                raise falcon.HTTPPayloadTooLarge(
+                raise falcon.HTTPContentTooLarge(
                     title='Request body is too large', description=msg)
 
         return hook
@@ -830,7 +830,7 @@ Here's the ASGI version of the app from above. Note that it uses the
                 msg = ('The size of the request is too large. The body must not '
                        'exceed ' + str(limit) + ' bytes in length.')
 
-                raise falcon.HTTPPayloadTooLarge(
+                raise falcon.HTTPContentTooLarge(
                     title='Request body is too large', description=msg)
 
         return hook
@@ -978,13 +978,17 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 
+.. |Build status| image:: https://github.com/falconry/falcon/actions/workflows/tests.yaml/badge.svg
+    :target: https://github.com/falconry/falcon/actions/workflows/tests.yaml
 .. |Docs| image:: https://readthedocs.org/projects/falcon/badge/?version=stable
     :alt: Falcon web framework docs
     :target: https://falcon.readthedocs.io/en/stable/?badge=stable
-.. |Build Status| image:: https://github.com/falconry/falcon/workflows/Run%20tests/badge.svg
-    :target: https://github.com/falconry/falcon/actions?query=workflow%3A%22Run+tests%22
 .. |codecov.io| image:: https://codecov.io/gh/falconry/falcon/branch/master/graphs/badge.svg
-    :target: http://codecov.io/gh/falconry/falcon
+    :target: https://codecov.io/gh/falconry/falcon
+.. |PyPI package| image:: https://badge.fury.io/py/falcon.svg
+    :target: https://pypi.org/project/falcon/
+.. |Python versions| image:: https://img.shields.io/pypi/pyversions/falcon.svg
+    :target: https://pypi.org/project/falcon/
 .. |Backer:GovCert| image:: https://falconframework.org/assets/govcert.png
     :alt: CERT Gouvernemental Luxembourg
     :height: 60px
diff --git a/docs/_newsfragments/1367.newandimproved.rst b/docs/_newsfragments/1367.newandimproved.rst
new file mode 100644
index 00000000..e4b4e5c0
--- /dev/null
+++ b/docs/_newsfragments/1367.newandimproved.rst
@@ -0,0 +1,3 @@
+The new implementation of :ref:`media type utilities <mediatype_util>`
+(Falcon was using the ``python-mimeparse`` library before) now always favors
+the exact media type match, if one is available.
diff --git a/docs/_newsfragments/1457.breakingchange.rst b/docs/_newsfragments/1457.breakingchange.rst
new file mode 100644
index 00000000..5b54a2ad
--- /dev/null
+++ b/docs/_newsfragments/1457.breakingchange.rst
@@ -0,0 +1,25 @@
+A number of undocumented internal helpers were renamed to start with an
+underscore, indicating they are private methods intended to be used only by the
+framework itself:
+
+* ``falcon.request_helpers.header_property`` →
+  ``falcon.request_helpers._header_property``
+* ``falcon.request_helpers.parse_cookie_header`` →
+  ``falcon.request_helpers._parse_cookie_header``
+* ``falcon.response_helpers.format_content_disposition`` →
+  ``falcon.response_helpers._format_content_disposition``
+* ``falcon.response_helpers.format_etag_header`` →
+  ``falcon.response_helpers._format_etag_header``
+* ``falcon.response_helpers.format_header_value_list`` →
+  ``falcon.response_helpers._format_header_value_list``
+* ``falcon.response_helpers.format_range`` →
+  ``falcon.response_helpers._format_range``
+* ``falcon.response_helpers.header_property`` →
+  ``falcon.response_helpers._header_property``
+* ``falcon.response_helpers.is_ascii_encodable`` →
+  ``falcon.response_helpers._is_ascii_encodable``
+
+If you were relying on these internal helpers, you can either copy the
+implementation into your codebase, or switch to the underscored variants.
+(Needless to say, though, we strongly recommend against referencing private
+methods, as we provide no SemVer guarantees for them.)
diff --git a/docs/_newsfragments/1853.breakingchange.rst b/docs/_newsfragments/1853.breakingchange.rst
index 9333b010..04546482 100644
--- a/docs/_newsfragments/1853.breakingchange.rst
+++ b/docs/_newsfragments/1853.breakingchange.rst
@@ -29,6 +29,14 @@ removed:
 * The ``falcon-print-routes`` command-line utility is no longer supported;
   ``falcon-inspect-app`` is a direct replacement.
 
+* :class:`falcon.stream.BoundedStream` is no longer re-imported via
+  ``falcon.request_helpers``.
+  If needed, import it directly as :class:`falcon.stream.BoundedStream`.
+
+* A deprecated alias of :class:`falcon.stream.BoundedStream`,
+  ``falcon.stream.Body``, was removed. Use :class:`falcon.stream.BoundedStream`
+  instead.
+
 * A deprecated utility function, ``falcon.get_http_status()``, was removed.
   Please use :meth:`falcon.code_to_http_status` instead.
 
diff --git a/docs/_newsfragments/2023.newandimproved.rst b/docs/_newsfragments/2023.newandimproved.rst
new file mode 100644
index 00000000..b5001079
--- /dev/null
+++ b/docs/_newsfragments/2023.newandimproved.rst
@@ -0,0 +1,4 @@
+The default error serializer will now use the response media handlers
+to better negotiate the response serialization with the client.
+The implementation still defaults to JSON if the client does not indicate any
+preference.
diff --git a/docs/_newsfragments/2276.newandimproved.rst b/docs/_newsfragments/2276.newandimproved.rst
new file mode 100644
index 00000000..f8dea9c9
--- /dev/null
+++ b/docs/_newsfragments/2276.newandimproved.rst
@@ -0,0 +1,11 @@
+The class ``falcon.HTTPPayloadTooLarge`` was renamed to
+:class:`falcon.HTTPContentTooLarge`, together with the accompanying HTTP
+:ref:`status code <status>` update, in order to reflect the newest HTTP
+semantics as per
+`RFC 9110, Section 15.5.14 <https://datatracker.ietf.org/doc/html/rfc9110#status.413>`__.
+(The old class name remains available as a deprecated compatibility alias.)
+
+In addition, one new :ref:`status code constant <status>` was added:
+``falcon.HTTP_421`` (also available as ``falcon.HTTP_MISDIRECTED_REQUEST``)
+in accordance with
+`RFC 9110, Section 15.5.20 <https://datatracker.ietf.org/doc/html/rfc9110#status.421>`__.
diff --git a/docs/_newsfragments/2343.breakingchange.rst b/docs/_newsfragments/2343.breakingchange.rst
new file mode 100644
index 00000000..ba2284e2
--- /dev/null
+++ b/docs/_newsfragments/2343.breakingchange.rst
@@ -0,0 +1,5 @@
+Removed ``is_async`` argument from :meth:`~falcon.media.validators.jsonschema.validate`
+and the hooks :meth:`~falcon.before` and :meth:`~falcon.after` since it's
+no longer needed.
+Cython from 3.0 will correctly mark ``asnyc def`` as coroutine, making
+this argument no longer useful.
diff --git a/docs/_newsfragments/2349.newandimproved.rst b/docs/_newsfragments/2349.newandimproved.rst
new file mode 100644
index 00000000..a5d143f7
--- /dev/null
+++ b/docs/_newsfragments/2349.newandimproved.rst
@@ -0,0 +1,3 @@
+Added :attr:`falcon.testing.Result.content_type` and
+:attr:`falcon.testing.StreamedResult.content_type` as a utility accessor
+for the ``Content-Type`` header.
diff --git a/docs/_newsfragments/2355.newandimproved.rst b/docs/_newsfragments/2355.newandimproved.rst
new file mode 100644
index 00000000..8cbc812e
--- /dev/null
+++ b/docs/_newsfragments/2355.newandimproved.rst
@@ -0,0 +1,10 @@
+Added a new flag :attr:`~falcon.ResponseOptions.xml_error_serialization` that
+can be used to disable XML serialization of :class:`~falcon.HTTPError` when
+using the default error serializer (and the client preferred it).
+This flag currently defaults to ``True``, keeping the same behavior as the previous
+Falcon series; Falcon 5.0 will change the default to ``False``, with the objective of
+completely removing support for it in a future version.
+User that whish to retain support for XML serialization of the errors in the default
+error serializer should add an XML media handler.
+
+Deprecated the :meth:`~falcon.HTTPError.to_xml` method.
diff --git a/docs/_newsfragments/864.breakingchange.rst b/docs/_newsfragments/864.breakingchange.rst
new file mode 100644
index 00000000..0c518bc1
--- /dev/null
+++ b/docs/_newsfragments/864.breakingchange.rst
@@ -0,0 +1,36 @@
+Falcon is no longer vendoring the
+`python-mimeparse <https://github.com/falconry/python-mimeparse>`__ library;
+the relevant functionality has instead been reimplemented in the framework
+itself, fixing a handful of long-standing bugs in the new implementation.
+
+If you use standalone
+`python-mimeparse <https://github.com/falconry/python-mimeparse>`__ in your
+project, do not worry! We will continue to maintain it as a separate package
+under the Falconry umbrella (we took over about 3 years ago).
+
+The following new behaviors are considered breaking changes:
+
+* Previously, the iterable passed to
+  :meth:`req.client_prefers <falcon.Request.client_prefers>` had to be sorted in
+  the order of increasing desirability.
+  :func:`~falcon.mediatypes.best_match`, and by proxy
+  :meth:`~falcon.Request.client_prefers`, now consider the provided media types
+  to be sorted in the (more intuitive, we hope) order of decreasing
+  desirability.
+
+* Unlike ``python-mimeparse``, the new
+  :ref:`media type utilities <mediatype_util>` consider media types with
+  different values for the same parameters as non-matching.
+
+  One theoretically possible scenario where this change can affect you is only
+  installing a :ref:`media <media>` handler for a content type with parameters;
+  it then may not match media types with conflicting values (that used to match
+  before Falcon 4.0).
+  If this turns out to be the case, also
+  :ref:`install the same handler <custom_media_handlers>` for the generic
+  ``type/subtype`` without parameters.
+
+The new functions,
+:func:`falcon.mediatypes.quality` and :func:`falcon.mediatypes.best_match`,
+otherwise have the same signature as the corresponding methods from
+``python-mimeparse``.
diff --git a/docs/api/errors.rst b/docs/api/errors.rst
index 528d435e..1ac8d91c 100644
--- a/docs/api/errors.rst
+++ b/docs/api/errors.rst
@@ -150,7 +150,7 @@ Predefined Errors
 .. autoclass:: falcon.HTTPPreconditionFailed
     :members:
 
-.. autoclass:: falcon.HTTPPayloadTooLarge
+.. autoclass:: falcon.HTTPContentTooLarge
     :members:
 
 .. autoclass:: falcon.HTTPUriTooLong
diff --git a/docs/api/status.rst b/docs/api/status.rst
index 13f6a1fd..d861c38a 100644
--- a/docs/api/status.rst
+++ b/docs/api/status.rst
@@ -139,7 +139,7 @@ HTTPStatus
     HTTP_GONE = HTTP_410
     HTTP_LENGTH_REQUIRED = HTTP_411
     HTTP_PRECONDITION_FAILED = HTTP_412
-    HTTP_REQUEST_ENTITY_TOO_LARGE = HTTP_413
+    HTTP_CONTENT_TOO_LARGE = HTTP_413
     HTTP_REQUEST_URI_TOO_LONG = HTTP_414
     HTTP_UNSUPPORTED_MEDIA_TYPE = HTTP_415
     HTTP_REQUESTED_RANGE_NOT_SATISFIABLE = HTTP_416
@@ -168,7 +168,7 @@ HTTPStatus
     HTTP_410 = '410 Gone'
     HTTP_411 = '411 Length Required'
     HTTP_412 = '412 Precondition Failed'
-    HTTP_413 = '413 Payload Too Large'
+    HTTP_413 = '413 Content Too Large'
     HTTP_414 = '414 URI Too Long'
     HTTP_415 = '415 Unsupported Media Type'
     HTTP_416 = '416 Range Not Satisfiable'
diff --git a/docs/api/util.rst b/docs/api/util.rst
index 094bf443..254b4cb6 100644
--- a/docs/api/util.rst
+++ b/docs/api/util.rst
@@ -33,10 +33,14 @@ HTTP Status
 .. autofunction:: falcon.http_status_to_code
 .. autofunction:: falcon.code_to_http_status
 
+.. _mediatype_util:
+
 Media types
 -----------
 
 .. autofunction:: falcon.parse_header
+.. autofunction:: falcon.mediatypes.quality
+.. autofunction:: falcon.mediatypes.best_match
 
 Async
 -----
@@ -85,3 +89,9 @@ Other
 
 .. autoclass:: falcon.ETag
     :members:
+
+Type Aliases
+------------
+
+.. automodule:: falcon.typing
+    :members:
diff --git a/docs/changes/4.0.0.rst b/docs/changes/4.0.0.rst
index bd15d6a1..dea98a58 100644
--- a/docs/changes/4.0.0.rst
+++ b/docs/changes/4.0.0.rst
@@ -4,18 +4,31 @@ Changelog for Falcon 4.0.0
 Summary
 -------
 
-Falcon 4.0 is in development. The progress is tracked via the
-`Version 4.0 milestone <https://github.com/falconry/falcon/milestone/34>`__
-on GitHub.
+The alpha release of Falcon 4.0.0 is here!
+
+We would really appreciate if you could help with early testing on your code!
+You can grab this release from PyPI (``pip install falcon==4.0.0a2``),
+and :ref:`let us know how it went <chat>`!
+
+Although we are still in the process of polishing the last items from the
+`version 4.0 milestone <https://github.com/falconry/falcon/milestone/34>`__,
+we do not expect (m)any radical changes from now on until Falcon 4.0.0 final.
+The full changelog of new features, breaking changes and fixes will be rendered
+as part of the first beta release.
 
 
 Changes to Supported Platforms
 ------------------------------
 
-- CPython 3.11 is now fully supported. (`#2072 <https://github.com/falconry/falcon/issues/2072>`__)
-- CPython 3.12 is now fully supported. (`#2196 <https://github.com/falconry/falcon/issues/2196>`__)
-- CPython 3.13 is now fully supported. (`#2258 <https://github.com/falconry/falcon/issues/2258>`__)
-- End-of-life Python 3.5, 3.6 & 3.7 are no longer supported. (`#2074 <https://github.com/falconry/falcon/pull/2074>`__, `#2273 <https://github.com/falconry/falcon/pull/2273>`__)
+- CPython 3.11 is now fully supported.
+  (`#2072 <https://github.com/falconry/falcon/issues/2072>`__)
+- CPython 3.12 is now fully supported.
+  (`#2196 <https://github.com/falconry/falcon/issues/2196>`__)
+- CPython 3.13 is now fully supported.
+  (`#2258 <https://github.com/falconry/falcon/issues/2258>`__)
+- End-of-life Python 3.5, 3.6 & 3.7 are no longer supported.
+  (`#2074 <https://github.com/falconry/falcon/pull/2074>`__,
+  `#2273 <https://github.com/falconry/falcon/pull/2273>`__)
 - Soon end-of-life Python 3.8 is no longer actively supported, but
   the framework should still continue to install from source and function.
 - The Falcon 4.x series is guaranteed to support CPython 3.10 and
@@ -32,17 +45,38 @@ now typed, further type annotations may be added throughout the 4.x release cycl
 To improve them, we may introduce changes to the typing that do not affect
 runtime behavior, but may surface new or different errors with type checkers.
 
+.. role:: python(code)
+    :language: python
+
 .. note::
+    All undocumented type aliases coming from ``falcon._typing`` are considered
+    private to the framework itself, and not meant for annotating applications
+    using Falcon. To that end, it is advisable to only use classes from the
+    public interface, and public aliases from :mod:`falcon.typing`, e.g.:
+
+    .. code-block:: python
 
-  All type aliases in falcon are considered private, and if used should be
-  imported inside ``if TYPE_CHECKING:`` blocks to avoid possible import errors
-  after an update.
+        class MyResource:
+            def on_get(self, req: falcon.Request, resp: falcon.Response) -> None:
+                resp.media = {'message': 'Hello, World!'}
+
+    If you still decide to reuse the private aliases anyway, they should
+    preferably be imported inside :python:`if TYPE_CHECKING:` blocks in order
+    to avoid possible runtime errors after an update.
+    Also, make sure to :ref:`let us know <chat>` which essential aliases are
+    missing from the public interface!
 
 .. towncrier release notes start
 
 Contributors to this Release
 ----------------------------
 
+.. note::
+    If we missed you below, don’t worry!
+
+    We will refresh the full list of contributors before the 4.0.0 final
+    release.
+
 Many thanks to all of our talented and stylish contributors for this release!
 
 - `aarcex3 <https://github.com/aarcex3>`__
@@ -67,7 +101,9 @@ Many thanks to all of our talented and stylish contributors for this release!
 - `mgorny <https://github.com/mgorny>`__
 - `mihaitodor <https://github.com/mihaitodor>`__
 - `MRLab12 <https://github.com/MRLab12>`__
+- `myusko <https://github.com/myusko>`__
 - `nfsec <https://github.com/nfsec>`__
+- `prathik2401 <https://github.com/prathik2401>`__
 - `RioAtHome <https://github.com/RioAtHome>`__
 - `TigreModerata <https://github.com/TigreModerata>`__
 - `vgerak <https://github.com/vgerak>`__
diff --git a/docs/conf.py b/docs/conf.py
index 492c0028..6edb23d0 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -77,6 +77,7 @@ extensions = [
     'sphinx_design',
     'myst_parser',
     # Falcon-specific extensions
+    'ext.autodoc_customizations',
     'ext.cibuildwheel',
     'ext.doorway',
     'ext.private_args',
diff --git a/docs/ext/autodoc_customizations.py b/docs/ext/autodoc_customizations.py
new file mode 100644
index 00000000..2f4d0795
--- /dev/null
+++ b/docs/ext/autodoc_customizations.py
@@ -0,0 +1,10 @@
+"""Customizations to the autodoc functionalities"""
+
+import sphinx.ext.autodoc as ad
+
+
+def setup(app):
+    # avoid adding "alias of xyz"
+    ad.GenericAliasMixin.update_content = ad.DataDocumenterMixinBase.update_content
+
+    return {'parallel_read_safe': True}
diff --git a/docs/ext/rfc.py b/docs/ext/rfc.py
index abe13499..3633d6ce 100644
--- a/docs/ext/rfc.py
+++ b/docs/ext/rfc.py
@@ -22,6 +22,7 @@ formatted like this::
 
 import re
 
+IETF_DOCS = 'https://datatracker.ietf.org/doc/html'
 RFC_PATTERN = re.compile(r'RFC (\d{4}), Section ([\d\.]+)')
 
 
@@ -39,11 +40,10 @@ def _process_line(line):
     section = m.group(2)
 
     template = (
-        '`RFC {rfc}, Section {section} '
-        '<https://tools.ietf.org/html/rfc{rfc}#section-{section}>`_'
+        '`RFC {rfc}, Section {section} <{ietf_docs}/rfc{rfc}#section-{section}>`__'
     )
 
-    rendered_text = template.format(rfc=rfc, section=section)
+    rendered_text = template.format(rfc=rfc, section=section, ietf_docs=IETF_DOCS)
 
     return line[: m.start()] + rendered_text + line[m.end() :]
 
diff --git a/docs/index.rst b/docs/index.rst
index 359062b5..32965979 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -16,26 +16,8 @@ We like to think of Falcon as the *Dieter Rams* of web frameworks. Falcon
 encourages the REST architectural style, and tries to do as little as possible
 while remaining highly effective.
 
-.. code:: python
-
-    import falcon
-
-    class QuoteResource:
-        def on_get(self, req, resp):
-            """Handles GET requests"""
-            quote = {
-                'quote': (
-                    "I've always been more interested in "
-                    "the future than in the past."
-                ),
-                'author': 'Grace Hopper'
-            }
-
-            resp.media = quote
-
-
-    app = falcon.App()
-    app.add_route('/quote', QuoteResource())
+.. literalinclude:: ../examples/quote.py
+    :language: python
 
 For a fully working example, check out the :ref:`quickstart`.
 
diff --git a/docs/user/intro.rst b/docs/user/intro.rst
index c6c22f22..ba35f619 100644
--- a/docs/user/intro.rst
+++ b/docs/user/intro.rst
@@ -87,7 +87,7 @@ Now, if you do make changes to Falcon itself, please consider contributing your
 Falcon License
 --------------
 
-Copyright 2012-2017 by Rackspace Hosting, Inc. and other contributors,
+Copyright 2012-2024 by Rackspace Hosting, Inc. and other contributors,
 as noted in the individual source code files.
 
 Licensed under the Apache License, Version 2.0 (the "License");
diff --git a/e2e-tests/conftest.py b/e2e-tests/conftest.py
index 342d177c..ecc84260 100644
--- a/e2e-tests/conftest.py
+++ b/e2e-tests/conftest.py
@@ -1,4 +1,4 @@
-# Copyright 2020-2021 by Vytautas Liuolia.
+# Copyright 2020-2024 by Vytautas Liuolia.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/e2e-tests/test_e2e.py b/e2e-tests/test_e2e.py
index 9bc02df8..8d33f888 100644
--- a/e2e-tests/test_e2e.py
+++ b/e2e-tests/test_e2e.py
@@ -1,4 +1,4 @@
-# Copyright 2020-2021 by Vytautas Liuolia.
+# Copyright 2020-2024 by Vytautas Liuolia.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/examples/quote.py b/examples/quote.py
new file mode 100644
index 00000000..eb6a6246
--- /dev/null
+++ b/examples/quote.py
@@ -0,0 +1,14 @@
+import falcon
+
+
+class QuoteResource:
+    def on_get(self, req: falcon.Request, resp: falcon.Response) -> None:
+        """Handle GET requests."""
+        resp.media = {
+            'quote': "I've always been more interested in the future than in the past.",
+            'author': 'Grace Hopper',
+        }
+
+
+app = falcon.App()
+app.add_route('/quote', QuoteResource())
diff --git a/examples/things_advanced.py b/examples/things_advanced.py
index e9d8b780..5ebd1e7e 100644
--- a/examples/things_advanced.py
+++ b/examples/things_advanced.py
@@ -141,7 +141,7 @@ def max_body(limit):
                 'exceed ' + str(limit) + ' bytes in length.'
             )
 
-            raise falcon.HTTPPayloadTooLarge(
+            raise falcon.HTTPContentTooLarge(
                 title='Request body is too large', description=msg
             )
 
diff --git a/examples/things_advanced_asgi.py b/examples/things_advanced_asgi.py
index f6fc6bf5..c640b32c 100644
--- a/examples/things_advanced_asgi.py
+++ b/examples/things_advanced_asgi.py
@@ -143,7 +143,7 @@ def max_body(limit):
                 'exceed ' + str(limit) + ' bytes in length.'
             )
 
-            raise falcon.HTTPPayloadTooLarge(
+            raise falcon.HTTPContentTooLarge(
                 title='Request body is too large', description=msg
             )
 
diff --git a/falcon/__init__.py b/falcon/__init__.py
index 542323b3..55ea9cde 100644
--- a/falcon/__init__.py
+++ b/falcon/__init__.py
@@ -84,6 +84,7 @@ __all__ = (
     'http_status_to_code',
     'IS_64_BITS',
     'is_python_func',
+    'mediatypes',
     'misc',
     'parse_header',
     'reader',
@@ -123,6 +124,7 @@ __all__ = (
     'HTTPNotAcceptable',
     'HTTPNotFound',
     'HTTPNotImplemented',
+    'HTTPContentTooLarge',
     'HTTPPayloadTooLarge',
     'HTTPPreconditionFailed',
     'HTTPPreconditionRequired',
@@ -137,6 +139,8 @@ __all__ = (
     'HTTPUnsupportedMediaType',
     'HTTPUriTooLong',
     'HTTPVersionNotSupported',
+    'InvalidMediaRange',
+    'InvalidMediaType',
     'MediaMalformedError',
     'MediaNotFoundError',
     'MediaValidationError',
@@ -264,6 +268,7 @@ __all__ = (
     'HTTP_BAD_GATEWAY',
     'HTTP_BAD_REQUEST',
     'HTTP_CONFLICT',
+    'HTTP_CONTENT_TOO_LARGE',
     'HTTP_CONTINUE',
     'HTTP_CREATED',
     'HTTP_EARLY_HINTS',
@@ -303,7 +308,6 @@ __all__ = (
     'HTTP_PROCESSING',
     'HTTP_PROXY_AUTHENTICATION_REQUIRED',
     'HTTP_REQUESTED_RANGE_NOT_SATISFIABLE',
-    'HTTP_REQUEST_ENTITY_TOO_LARGE',
     'HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE',
     'HTTP_REQUEST_TIMEOUT',
     'HTTP_REQUEST_URI_TOO_LONG',
@@ -354,6 +358,7 @@ from falcon.errors import HeaderNotSupported
 from falcon.errors import HTTPBadGateway
 from falcon.errors import HTTPBadRequest
 from falcon.errors import HTTPConflict
+from falcon.errors import HTTPContentTooLarge
 from falcon.errors import HTTPFailedDependency
 from falcon.errors import HTTPForbidden
 from falcon.errors import HTTPGatewayTimeout
@@ -386,6 +391,8 @@ from falcon.errors import HTTPUnprocessableEntity
 from falcon.errors import HTTPUnsupportedMediaType
 from falcon.errors import HTTPUriTooLong
 from falcon.errors import HTTPVersionNotSupported
+from falcon.errors import InvalidMediaRange
+from falcon.errors import InvalidMediaType
 from falcon.errors import MediaMalformedError
 from falcon.errors import MediaNotFoundError
 from falcon.errors import MediaValidationError
@@ -529,6 +536,7 @@ from falcon.status_codes import HTTP_ALREADY_REPORTED
 from falcon.status_codes import HTTP_BAD_GATEWAY
 from falcon.status_codes import HTTP_BAD_REQUEST
 from falcon.status_codes import HTTP_CONFLICT
+from falcon.status_codes import HTTP_CONTENT_TOO_LARGE
 from falcon.status_codes import HTTP_CONTINUE
 from falcon.status_codes import HTTP_CREATED
 from falcon.status_codes import HTTP_EARLY_HINTS
@@ -567,7 +575,6 @@ from falcon.status_codes import HTTP_PRECONDITION_FAILED
 from falcon.status_codes import HTTP_PRECONDITION_REQUIRED
 from falcon.status_codes import HTTP_PROCESSING
 from falcon.status_codes import HTTP_PROXY_AUTHENTICATION_REQUIRED
-from falcon.status_codes import HTTP_REQUEST_ENTITY_TOO_LARGE
 from falcon.status_codes import HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE
 from falcon.status_codes import HTTP_REQUEST_TIMEOUT
 from falcon.status_codes import HTTP_REQUEST_URI_TOO_LONG
@@ -615,6 +622,7 @@ from falcon.util import http_now
 from falcon.util import http_status_to_code
 from falcon.util import IS_64_BITS
 from falcon.util import is_python_func
+from falcon.util import mediatypes
 from falcon.util import misc
 from falcon.util import parse_header
 from falcon.util import reader
diff --git a/falcon/_typing.py b/falcon/_typing.py
new file mode 100644
index 00000000..d82a5bac
--- /dev/null
+++ b/falcon/_typing.py
@@ -0,0 +1,198 @@
+# Copyright 2021-2024 by Vytautas Liuolia.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Private type aliases used internally by Falcon.."""
+
+from __future__ import annotations
+
+from enum import auto
+from enum import Enum
+import http
+from http.cookiejar import Cookie
+import sys
+from typing import (
+    Any,
+    Awaitable,
+    Callable,
+    Dict,
+    Iterable,
+    List,
+    Literal,
+    Mapping,
+    Optional,
+    Pattern,
+    Protocol,
+    Tuple,
+    TYPE_CHECKING,
+    TypeVar,
+    Union,
+)
+
+# NOTE(vytas): Mypy still struggles to handle a conditional import in the EAFP
+#   fashion, so we branch on Py version instead (which it does understand).
+if sys.version_info >= (3, 11):
+    from wsgiref.types import StartResponse as StartResponse
+    from wsgiref.types import WSGIEnvironment as WSGIEnvironment
+else:
+    WSGIEnvironment = Dict[str, Any]
+    StartResponse = Callable[[str, List[Tuple[str, str]]], Callable[[bytes], None]]
+
+if TYPE_CHECKING:
+    from falcon.asgi import Request as AsgiRequest
+    from falcon.asgi import Response as AsgiResponse
+    from falcon.asgi import WebSocket
+    from falcon.asgi_spec import AsgiEvent
+    from falcon.asgi_spec import AsgiSendMsg
+    from falcon.http_error import HTTPError
+    from falcon.request import Request
+    from falcon.response import Response
+
+
+class _Unset(Enum):
+    UNSET = auto()
+
+
+_T = TypeVar('_T')
+_UNSET = _Unset.UNSET
+UnsetOr = Union[Literal[_Unset.UNSET], _T]
+
+Link = Dict[str, str]
+CookieArg = Mapping[str, Union[str, Cookie]]
+# Error handlers
+ErrorHandler = Callable[['Request', 'Response', BaseException, Dict[str, Any]], None]
+
+
+class AsgiErrorHandler(Protocol):
+    async def __call__(
+        self,
+        req: AsgiRequest,
+        resp: Optional[AsgiResponse],
+        error: BaseException,
+        params: Dict[str, Any],
+        *,
+        ws: Optional[WebSocket] = ...,
+    ) -> None: ...
+
+
+# Error serializers
+ErrorSerializer = Callable[['Request', 'Response', 'HTTPError'], None]
+
+# Sinks
+SinkPrefix = Union[str, Pattern[str]]
+
+
+class SinkCallable(Protocol):
+    def __call__(self, req: Request, resp: Response, **kwargs: str) -> None: ...
+
+
+class AsgiSinkCallable(Protocol):
+    async def __call__(
+        self, req: AsgiRequest, resp: AsgiResponse, **kwargs: str
+    ) -> None: ...
+
+
+HeaderMapping = Mapping[str, str]
+HeaderIter = Iterable[Tuple[str, str]]
+HeaderArg = Union[HeaderMapping, HeaderIter]
+ResponseStatus = Union[http.HTTPStatus, str, int]
+StoreArg = Optional[Dict[str, Any]]
+Resource = object
+RangeSetHeader = Union[Tuple[int, int, int], Tuple[int, int, int, str]]
+
+
+# WSGI
+class ResponderMethod(Protocol):
+    def __call__(
+        self,
+        resource: Resource,
+        req: Request,
+        resp: Response,
+        **kwargs: Any,
+    ) -> None: ...
+
+
+class ResponderCallable(Protocol):
+    def __call__(self, req: Request, resp: Response, **kwargs: Any) -> None: ...
+
+
+ProcessRequestMethod = Callable[['Request', 'Response'], None]
+ProcessResourceMethod = Callable[
+    ['Request', 'Response', Resource, Dict[str, Any]], None
+]
+ProcessResponseMethod = Callable[['Request', 'Response', Resource, bool], None]
+
+
+# ASGI
+class AsgiResponderMethod(Protocol):
+    async def __call__(
+        self,
+        resource: Resource,
+        req: AsgiRequest,
+        resp: AsgiResponse,
+        **kwargs: Any,
+    ) -> None: ...
+
+
+class AsgiResponderCallable(Protocol):
+    async def __call__(
+        self, req: AsgiRequest, resp: AsgiResponse, **kwargs: Any
+    ) -> None: ...
+
+
+class AsgiResponderWsCallable(Protocol):
+    async def __call__(
+        self, req: AsgiRequest, ws: WebSocket, **kwargs: Any
+    ) -> None: ...
+
+
+AsgiReceive = Callable[[], Awaitable['AsgiEvent']]
+AsgiSend = Callable[['AsgiSendMsg'], Awaitable[None]]
+AsgiProcessRequestMethod = Callable[['AsgiRequest', 'AsgiResponse'], Awaitable[None]]
+AsgiProcessResourceMethod = Callable[
+    ['AsgiRequest', 'AsgiResponse', Resource, Dict[str, Any]], Awaitable[None]
+]
+AsgiProcessResponseMethod = Callable[
+    ['AsgiRequest', 'AsgiResponse', Resource, bool], Awaitable[None]
+]
+AsgiProcessRequestWsMethod = Callable[['AsgiRequest', 'WebSocket'], Awaitable[None]]
+AsgiProcessResourceWsMethod = Callable[
+    ['AsgiRequest', 'WebSocket', Resource, Dict[str, Any]], Awaitable[None]
+]
+ResponseCallbacks = Union[
+    Tuple[Callable[[], None], Literal[False]],
+    Tuple[Callable[[], Awaitable[None]], Literal[True]],
+]
+
+
+# Routing
+
+MethodDict = Union[
+    Dict[str, ResponderCallable],
+    Dict[str, Union[AsgiResponderCallable, AsgiResponderWsCallable]],
+]
+
+
+class FindMethod(Protocol):
+    def __call__(
+        self, uri: str, req: Optional[Request]
+    ) -> Optional[Tuple[object, MethodDict, Dict[str, Any], Optional[str]]]: ...
+
+
+# Media
+class SerializeSync(Protocol):
+    def __call__(self, media: Any, content_type: Optional[str] = ...) -> bytes: ...
+
+
+DeserializeSync = Callable[[bytes], Any]
+
+Responder = Union[ResponderMethod, AsgiResponderMethod]
diff --git a/falcon/app.py b/falcon/app.py
index 56ba3528..9c764f55 100644
--- a/falcon/app.py
+++ b/falcon/app.py
@@ -14,6 +14,8 @@
 
 """Falcon App class."""
 
+from __future__ import annotations
+
 from functools import wraps
 from inspect import iscoroutinefunction
 import pathlib
@@ -43,6 +45,18 @@ from falcon import app_helpers as helpers
 from falcon import constants
 from falcon import responders
 from falcon import routing
+from falcon._typing import AsgiResponderCallable
+from falcon._typing import AsgiResponderWsCallable
+from falcon._typing import AsgiSinkCallable
+from falcon._typing import ErrorHandler
+from falcon._typing import ErrorSerializer
+from falcon._typing import FindMethod
+from falcon._typing import ProcessResponseMethod
+from falcon._typing import ResponderCallable
+from falcon._typing import SinkCallable
+from falcon._typing import SinkPrefix
+from falcon._typing import StartResponse
+from falcon._typing import WSGIEnvironment
 from falcon.errors import CompatibilityError
 from falcon.errors import HTTPBadRequest
 from falcon.errors import HTTPInternalServerError
@@ -54,19 +68,7 @@ from falcon.request import RequestOptions
 from falcon.response import Response
 from falcon.response import ResponseOptions
 import falcon.status_codes as status
-from falcon.typing import AsgiResponderCallable
-from falcon.typing import AsgiResponderWsCallable
-from falcon.typing import AsgiSinkCallable
-from falcon.typing import ErrorHandler
-from falcon.typing import ErrorSerializer
-from falcon.typing import FindMethod
-from falcon.typing import ProcessResponseMethod
 from falcon.typing import ReadableIO
-from falcon.typing import ResponderCallable
-from falcon.typing import SinkCallable
-from falcon.typing import SinkPrefix
-from falcon.typing import StartResponse
-from falcon.typing import WSGIEnvironment
 from falcon.util import deprecation
 from falcon.util import misc
 from falcon.util.misc import code_to_http_status
@@ -301,8 +303,8 @@ class App:
     def __init__(
         self,
         media_type: str = constants.DEFAULT_MEDIA_TYPE,
-        request_type: Type[Request] = Request,
-        response_type: Type[Response] = Response,
+        request_type: Optional[Type[Request]] = None,
+        response_type: Optional[Type[Response]] = None,
         middleware: Union[object, Iterable[object]] = None,
         router: Optional[routing.CompiledRouter] = None,
         independent_middleware: bool = True,
@@ -340,8 +342,8 @@ class App:
         self._router = router or routing.DefaultRouter()
         self._router_search = self._router.find
 
-        self._request_type = request_type
-        self._response_type = response_type
+        self._request_type = request_type or Request
+        self._response_type = response_type or Response
 
         self._error_handlers = {}
         self._serialize_error = helpers.default_serialize_error
diff --git a/falcon/app_helpers.py b/falcon/app_helpers.py
index bca38a3b..68ccc28d 100644
--- a/falcon/app_helpers.py
+++ b/falcon/app_helpers.py
@@ -20,20 +20,20 @@ from inspect import iscoroutinefunction
 from typing import IO, Iterable, List, Literal, Optional, overload, Tuple, Union
 
 from falcon import util
+from falcon._typing import AsgiProcessRequestMethod as APRequest
+from falcon._typing import AsgiProcessRequestWsMethod
+from falcon._typing import AsgiProcessResourceMethod as APResource
+from falcon._typing import AsgiProcessResourceWsMethod
+from falcon._typing import AsgiProcessResponseMethod as APResponse
+from falcon._typing import ProcessRequestMethod as PRequest
+from falcon._typing import ProcessResourceMethod as PResource
+from falcon._typing import ProcessResponseMethod as PResponse
 from falcon.constants import MEDIA_JSON
 from falcon.constants import MEDIA_XML
 from falcon.errors import CompatibilityError
 from falcon.errors import HTTPError
 from falcon.request import Request
 from falcon.response import Response
-from falcon.typing import AsgiProcessRequestMethod as APRequest
-from falcon.typing import AsgiProcessRequestWsMethod
-from falcon.typing import AsgiProcessResourceMethod as APResource
-from falcon.typing import AsgiProcessResourceWsMethod
-from falcon.typing import AsgiProcessResponseMethod as APResponse
-from falcon.typing import ProcessRequestMethod as PRequest
-from falcon.typing import ProcessResourceMethod as PResource
-from falcon.typing import ProcessResponseMethod as PResponse
 from falcon.util.sync import _wrap_non_coroutine_unsafe
 
 __all__ = (
@@ -291,7 +291,18 @@ def default_serialize_error(req: Request, resp: Response, exception: HTTPError)
         resp: Instance of ``falcon.Response``
         exception: Instance of ``falcon.HTTPError``
     """
-    preferred = req.client_prefers((MEDIA_XML, 'text/xml', MEDIA_JSON))
+    options = resp.options
+    predefined = (
+        [MEDIA_JSON, 'text/xml', MEDIA_XML]
+        if options.xml_error_serialization
+        else [MEDIA_JSON]
+    )
+    media_handlers = [mt for mt in options.media_handlers if mt not in predefined]
+    # NOTE(caselit,vytas): Add the registered handlers after the predefined
+    #   ones. This ensures that in the case of an equal match, the first one
+    #   (JSON) is selected and that the q parameter is taken into consideration
+    #   when selecting the media handler.
+    preferred = req.client_prefers(predefined + media_handlers)
 
     if preferred is None:
         # NOTE(kgriffs): See if the client expects a custom media
@@ -310,16 +321,27 @@ def default_serialize_error(req: Request, resp: Response, exception: HTTPError)
         if '+json' in accept:
             preferred = MEDIA_JSON
         elif '+xml' in accept:
+            # NOTE(caselit): ignore xml_error_serialization when
+            # checking if the media should be xml. This gives a chance to
+            # a xml media handler, if any, to be used
             preferred = MEDIA_XML
 
     if preferred is not None:
+        handler, _, _ = options.media_handlers._resolve(
+            preferred, MEDIA_JSON, raise_not_found=False
+        )
         if preferred == MEDIA_JSON:
-            handler, _, _ = resp.options.media_handlers._resolve(
-                MEDIA_JSON, MEDIA_JSON, raise_not_found=False
-            )
+            # NOTE(caselit): special case json to ensure that it's always possible to
+            # serialize an error in json even if no handler is set in the
+            # media_handlers.
             resp.data = exception.to_json(handler)
-        else:
-            resp.data = exception.to_xml()
+        elif handler:
+            # NOTE(caselit): Let the app serialize the response even if it needs
+            # to re-get the handler, since async handlers may not have a sync
+            # version available.
+            resp.media = exception.to_dict()
+        elif options.xml_error_serialization:
+            resp.data = exception._to_xml()
 
         # NOTE(kgriffs): No need to append the charset param, since
         #   utf-8 is the default for both JSON and XML.
diff --git a/falcon/asgi/_asgi_helpers.py b/falcon/asgi/_asgi_helpers.py
index 9bbd12e8..cf9e18bb 100644
--- a/falcon/asgi/_asgi_helpers.py
+++ b/falcon/asgi/_asgi_helpers.py
@@ -1,4 +1,4 @@
-# Copyright 2020 by Vytautas Liuolia.
+# Copyright 2020-2024 by Vytautas Liuolia.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/falcon/asgi/_request_helpers.py b/falcon/asgi/_request_helpers.py
index 9738e784..7bfbc2de 100644
--- a/falcon/asgi/_request_helpers.py
+++ b/falcon/asgi/_request_helpers.py
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
     from falcon.asgi import Request
 
 
-def header_property(header_name: str) -> Any:
+def _header_property(header_name: str) -> Any:
     """Create a read-only header property.
 
     Args:
diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py
index 9b69eb58..f3b63780 100644
--- a/falcon/asgi/app.py
+++ b/falcon/asgi/app.py
@@ -14,6 +14,8 @@
 
 """ASGI application class."""
 
+from __future__ import annotations
+
 import asyncio
 from inspect import isasyncgenfunction
 from inspect import iscoroutinefunction
@@ -38,6 +40,14 @@ from typing import (
 from falcon import constants
 from falcon import responders
 from falcon import routing
+from falcon._typing import _UNSET
+from falcon._typing import AsgiErrorHandler
+from falcon._typing import AsgiReceive
+from falcon._typing import AsgiResponderCallable
+from falcon._typing import AsgiResponderWsCallable
+from falcon._typing import AsgiSend
+from falcon._typing import AsgiSinkCallable
+from falcon._typing import SinkPrefix
 import falcon.app
 from falcon.app_helpers import AsyncPreparedMiddlewareResult
 from falcon.app_helpers import AsyncPreparedMiddlewareWsResult
@@ -53,14 +63,6 @@ from falcon.errors import WebSocketDisconnected
 from falcon.http_error import HTTPError
 from falcon.http_status import HTTPStatus
 from falcon.media.multipart import MultipartFormHandler
-from falcon.typing import AsgiErrorHandler
-from falcon.typing import AsgiReceive
-from falcon.typing import AsgiResponderCallable
-from falcon.typing import AsgiResponderWsCallable
-from falcon.typing import AsgiSend
-from falcon.typing import AsgiSinkCallable
-from falcon.typing import MISSING
-from falcon.typing import SinkPrefix
 from falcon.util import get_argnames
 from falcon.util.misc import is_python_func
 from falcon.util.sync import _should_wrap_non_coroutines
@@ -364,8 +366,8 @@ class App(falcon.app.App):
     def __init__(
         self,
         media_type: str = constants.DEFAULT_MEDIA_TYPE,
-        request_type: Type[Request] = Request,
-        response_type: Type[Response] = Response,
+        request_type: Optional[Type[Request]] = None,
+        response_type: Optional[Type[Response]] = None,
         middleware: Union[object, Iterable[object]] = None,
         router: Optional[routing.CompiledRouter] = None,
         independent_middleware: bool = True,
@@ -374,8 +376,8 @@ class App(falcon.app.App):
     ) -> None:
         super().__init__(
             media_type,
-            request_type,
-            response_type,
+            request_type or Request,
+            response_type or Response,
             middleware,
             router,
             independent_middleware,
@@ -384,7 +386,7 @@ class App(falcon.app.App):
         )
 
         self.ws_options = WebSocketOptions()
-        self._standard_response_type = response_type is Response
+        self._standard_response_type = response_type in (None, Response)
 
         self.add_error_handler(
             WebSocketDisconnected, self._ws_disconnected_error_handler
@@ -552,9 +554,9 @@ class App(falcon.app.App):
                     data = resp._data
 
                     if data is None and resp._media is not None:
-                        # NOTE(kgriffs): We use a special MISSING singleton since
+                        # NOTE(kgriffs): We use a special _UNSET singleton since
                         #   None is ambiguous (the media handler might return None).
-                        if resp._media_rendered is MISSING:
+                        if resp._media_rendered is _UNSET:
                             opt = resp.options
                             if not resp.content_type:
                                 resp.content_type = opt.default_media_type
diff --git a/falcon/asgi/multipart.py b/falcon/asgi/multipart.py
index 403028b2..85d98e30 100644
--- a/falcon/asgi/multipart.py
+++ b/falcon/asgi/multipart.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2023 by Vytautas Liuolia.
+# Copyright 2019-2024 by Vytautas Liuolia.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -25,11 +25,11 @@ from typing import (
     TYPE_CHECKING,
 )
 
+from falcon._typing import _UNSET
 from falcon.asgi.reader import BufferedReader
 from falcon.errors import DelimiterError
 from falcon.media import multipart
 from falcon.typing import AsyncReadableIO
-from falcon.typing import MISSING
 from falcon.util.mediatypes import parse_header
 
 if TYPE_CHECKING:
@@ -102,7 +102,7 @@ class BodyPart(multipart.BodyPart):
         Returns:
             object: The deserialized media representation.
         """
-        if self._media is MISSING:
+        if self._media is _UNSET:
             handler, _, _ = self._parse_options.media_handlers._resolve(
                 self.content_type, 'text/plain'
             )
diff --git a/falcon/asgi/reader.py b/falcon/asgi/reader.py
index c2adda79..1d748dd8 100644
--- a/falcon/asgi/reader.py
+++ b/falcon/asgi/reader.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2020 by Vytautas Liuolia.
+# Copyright 2019-2024 by Vytautas Liuolia.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/falcon/asgi/request.py b/falcon/asgi/request.py
index 5fb62288..ebd2e382 100644
--- a/falcon/asgi/request.py
+++ b/falcon/asgi/request.py
@@ -34,13 +34,13 @@ from typing import (
 from falcon import errors
 from falcon import request
 from falcon import request_helpers as helpers
+from falcon._typing import _UNSET
+from falcon._typing import AsgiReceive
+from falcon._typing import StoreArg
+from falcon._typing import UnsetOr
 from falcon.asgi_spec import AsgiEvent
 from falcon.constants import SINGLETON_HEADERS
 from falcon.forwarded import Forwarded
-from falcon.typing import AsgiReceive
-from falcon.typing import MISSING
-from falcon.typing import MissingOr
-from falcon.typing import StoreArgument
 from falcon.util import deprecation
 from falcon.util import ETag
 from falcon.util.uri import parse_host
@@ -100,7 +100,7 @@ class Request(request.Request):
     _cached_prefix: Optional[str] = None
     _cached_relative_uri: Optional[str] = None
     _cached_uri: Optional[str] = None
-    _media: MissingOr[Any] = MISSING
+    _media: UnsetOr[Any] = _UNSET
     _media_error: Optional[Exception] = None
     _stream: Optional[BoundedStream] = None
 
@@ -163,13 +163,13 @@ class Request(request.Request):
         self.scope = scope
         self.is_websocket = scope['type'] == 'websocket'
 
-        self.options = options if options else request.RequestOptions()
+        self.options = options if options is not None else request.RequestOptions()
 
         self.method = 'GET' if self.is_websocket else scope['method']
 
         self.uri_template = None
         # PERF(vytas): Fall back to class variable(s) when unset.
-        # self._media = MISSING
+        # self._media = _UNSET
         # self._media_error = None
 
         # TODO(kgriffs): ASGI does not specify whether 'path' may be empty,
@@ -260,11 +260,11 @@ class Request(request.Request):
     # trouble.
     # ------------------------------------------------------------------------
 
-    auth: Optional[str] = asgi_helpers.header_property('Authorization')
-    expect: Optional[str] = asgi_helpers.header_property('Expect')
-    if_range: Optional[str] = asgi_helpers.header_property('If-Range')
-    referer: Optional[str] = asgi_helpers.header_property('Referer')
-    user_agent: Optional[str] = asgi_helpers.header_property('User-Agent')
+    auth: Optional[str] = asgi_helpers._header_property('Authorization')
+    expect: Optional[str] = asgi_helpers._header_property('Expect')
+    if_range: Optional[str] = asgi_helpers._header_property('If-Range')
+    referer: Optional[str] = asgi_helpers._header_property('Referer')
+    user_agent: Optional[str] = asgi_helpers._header_property('User-Agent')
 
     @property
     def accept(self) -> str:
@@ -564,7 +564,7 @@ class Request(request.Request):
 
         return netloc_value
 
-    async def get_media(self, default_when_empty: MissingOr[Any] = MISSING) -> Any:
+    async def get_media(self, default_when_empty: UnsetOr[Any] = _UNSET) -> Any:
         """Return a deserialized form of the request stream.
 
         The first time this method is called, the request stream will be
@@ -606,10 +606,10 @@ class Request(request.Request):
             media (object): The deserialized media representation.
         """
 
-        if self._media is not MISSING:
+        if self._media is not _UNSET:
             return self._media
         if self._media_error is not None:
-            if default_when_empty is not MISSING and isinstance(
+            if default_when_empty is not _UNSET and isinstance(
                 self._media_error, errors.MediaNotFoundError
             ):
                 return default_when_empty
@@ -629,7 +629,7 @@ class Request(request.Request):
 
         except errors.MediaNotFoundError as err:
             self._media_error = err
-            if default_when_empty is not MISSING:
+            if default_when_empty is not _UNSET:
                 return default_when_empty
             raise
         except Exception as err:
@@ -655,7 +655,7 @@ class Request(request.Request):
         # TODO(kgriffs): It may make sense at some point to create a
         #   header property generator that DRY's up the memoization
         #   pattern for us.
-        if self._cached_if_match is MISSING:
+        if self._cached_if_match is _UNSET:
             header_value = self._asgi_headers.get(b'if-match')
             if header_value:
                 self._cached_if_match = helpers._parse_etags(
@@ -668,7 +668,7 @@ class Request(request.Request):
 
     @property
     def if_none_match(self) -> Optional[List[Union[ETag, Literal['*']]]]:
-        if self._cached_if_none_match is MISSING:
+        if self._cached_if_none_match is _UNSET:
             header_value = self._asgi_headers.get(b'if-none-match')
             if header_value:
                 self._cached_if_none_match = helpers._parse_etags(
@@ -792,7 +792,7 @@ class Request(request.Request):
         self,
         name: str,
         required: Literal[True],
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         default: Optional[str] = ...,
     ) -> str: ...
 
@@ -801,7 +801,7 @@ class Request(request.Request):
         self,
         name: str,
         required: bool = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         *,
         default: str,
     ) -> str: ...
@@ -811,7 +811,7 @@ class Request(request.Request):
         self,
         name: str,
         required: bool = False,
-        store: StoreArgument = None,
+        store: StoreArg = None,
         default: Optional[str] = None,
     ) -> Optional[str]: ...
 
@@ -819,7 +819,7 @@ class Request(request.Request):
         self,
         name: str,
         required: bool = False,
-        store: StoreArgument = None,
+        store: StoreArg = None,
         default: Optional[str] = None,
     ) -> Optional[str]:
         """Return the raw value of a query string parameter as a string.
diff --git a/falcon/asgi/response.py b/falcon/asgi/response.py
index 73fcfbfb..1b571744 100644
--- a/falcon/asgi/response.py
+++ b/falcon/asgi/response.py
@@ -18,14 +18,22 @@ from __future__ import annotations
 
 from inspect import iscoroutine
 from inspect import iscoroutinefunction
-from typing import Awaitable, Callable, List, Literal, Optional, Tuple, Union
+from typing import (
+    AsyncIterator,
+    Awaitable,
+    Callable,
+    List,
+    Literal,
+    Optional,
+    Tuple,
+    Union,
+)
 
 from falcon import response
-from falcon.typing import AsyncIterator
+from falcon._typing import _UNSET
+from falcon._typing import ResponseCallbacks
 from falcon.typing import AsyncReadableIO
-from falcon.typing import MISSING
-from falcon.typing import ResponseCallbacks
-from falcon.typing import SseEmitter
+from falcon.typing import SSEEmitter
 from falcon.util.misc import _encode_items_to_latin1
 from falcon.util.misc import is_python_func
 
@@ -45,7 +53,7 @@ class Response(response.Response):
     # PERF(kgriffs): These will be shadowed when set on an instance; let's
     #   us avoid having to implement __init__ and incur the overhead of
     #   an additional function call.
-    _sse: Optional[SseEmitter] = None
+    _sse: Optional[SSEEmitter] = None
     _registered_callbacks: Optional[List[ResponseCallbacks]] = None
 
     stream: Union[AsyncReadableIO, AsyncIterator[bytes], None]  # type: ignore[assignment]
@@ -87,7 +95,7 @@ class Response(response.Response):
     """
 
     @property
-    def sse(self) -> Optional[SseEmitter]:
+    def sse(self) -> Optional[SSEEmitter]:
         """A Server-Sent Event (SSE) emitter, implemented as
         an async iterator or generator that yields a series of
         of :class:`falcon.asgi.SSEvent` instances. Each event will be
@@ -135,7 +143,7 @@ class Response(response.Response):
         return self._sse
 
     @sse.setter
-    def sse(self, value: Optional[SseEmitter]) -> None:
+    def sse(self, value: Optional[SSEEmitter]) -> None:
         self._sse = value
 
     def set_stream(
@@ -199,9 +207,9 @@ class Response(response.Response):
             data = self._data
 
             if data is None and self._media is not None:
-                # NOTE(kgriffs): We use a special MISSING singleton since
+                # NOTE(kgriffs): We use a special _UNSET singleton since
                 #   None is ambiguous (the media handler might return None).
-                if self._media_rendered is MISSING:
+                if self._media_rendered is _UNSET:
                     if not self.content_type:
                         self.content_type = self.options.default_media_type
 
diff --git a/falcon/asgi/stream.py b/falcon/asgi/stream.py
index 6213b1da..43bcc6e1 100644
--- a/falcon/asgi/stream.py
+++ b/falcon/asgi/stream.py
@@ -18,9 +18,9 @@ from __future__ import annotations
 
 from typing import AsyncIterator, NoReturn, Optional
 
+from falcon._typing import AsgiReceive
 from falcon.asgi_spec import AsgiEvent
 from falcon.errors import OperationNotAllowed
-from falcon.typing import AsgiReceive
 
 __all__ = ('BoundedStream',)
 
diff --git a/falcon/asgi/structures.py b/falcon/asgi/structures.py
index 7e66c310..91533a05 100644
--- a/falcon/asgi/structures.py
+++ b/falcon/asgi/structures.py
@@ -5,7 +5,6 @@ from typing import Optional
 from falcon.constants import MEDIA_JSON
 from falcon.media import BaseHandler
 from falcon.media.json import _DEFAULT_JSON_HANDLER
-from falcon.typing import JSONSerializable
 
 __all__ = ('SSEvent',)
 
@@ -60,7 +59,7 @@ class SSEvent:
     """String to use for the ``data`` field in the message.
     Will be encoded as UTF-8 in the event. Takes precedence over `json`.
     """
-    json: JSONSerializable
+    json: object
     """JSON-serializable object to be converted to JSON and used as the ``data``
     field in the event message.
     """
@@ -89,7 +88,7 @@ class SSEvent:
         self,
         data: Optional[bytes] = None,
         text: Optional[str] = None,
-        json: JSONSerializable = None,
+        json: Optional[object] = None,
         event: Optional[str] = None,
         event_id: Optional[str] = None,
         retry: Optional[int] = None,
diff --git a/falcon/asgi/ws.py b/falcon/asgi/ws.py
index c0203199..03800682 100644
--- a/falcon/asgi/ws.py
+++ b/falcon/asgi/ws.py
@@ -10,14 +10,14 @@ from typing import Any, Deque, Dict, Iterable, Mapping, Optional, Tuple, Union
 from falcon import errors
 from falcon import media
 from falcon import status_codes
+from falcon._typing import AsgiReceive
+from falcon._typing import AsgiSend
+from falcon._typing import HeaderArg
 from falcon.asgi_spec import AsgiEvent
 from falcon.asgi_spec import AsgiSendMsg
 from falcon.asgi_spec import EventType
 from falcon.asgi_spec import WSCloseCode
 from falcon.constants import WebSocketPayloadType
-from falcon.typing import AsgiReceive
-from falcon.typing import AsgiSend
-from falcon.typing import HeaderList
 from falcon.util import misc
 
 __all__ = ('WebSocket',)
@@ -144,7 +144,7 @@ class WebSocket:
     async def accept(
         self,
         subprotocol: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
     ) -> None:
         """Accept the incoming WebSocket connection.
 
@@ -166,7 +166,7 @@ class WebSocket:
                 client may choose to abandon the connection in this case,
                 if it does not receive an explicit protocol selection.
 
-            headers (HeaderList): An iterable of ``(name: str, value: str)``
+            headers (HeaderArg): An iterable of ``(name: str, value: str)``
                 two-item iterables, representing a collection of HTTP headers to
                 include in the handshake response. Both *name* and *value* must
                 be of type ``str`` and contain only US-ASCII characters.
diff --git a/falcon/cyutil/misc.pyx b/falcon/cyutil/misc.pyx
index b7962aa6..61b688cd 100644
--- a/falcon/cyutil/misc.pyx
+++ b/falcon/cyutil/misc.pyx
@@ -1,4 +1,4 @@
-# Copyright 2020-2021 by Vytautas Liuolia.
+# Copyright 2020-2024 by Vytautas Liuolia.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/falcon/cyutil/reader.pyx b/falcon/cyutil/reader.pyx
index 039e72a8..449983a9 100644
--- a/falcon/cyutil/reader.pyx
+++ b/falcon/cyutil/reader.pyx
@@ -1,4 +1,4 @@
-# Copyright 2019-2020 by Vytautas Liuolia.
+# Copyright 2019-2024 by Vytautas Liuolia.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/falcon/cyutil/uri.pyx b/falcon/cyutil/uri.pyx
index 477e7cc7..bb42cfdf 100644
--- a/falcon/cyutil/uri.pyx
+++ b/falcon/cyutil/uri.pyx
@@ -1,4 +1,4 @@
-# Copyright 2019-2020 by Vytautas Liuolia.
+# Copyright 2019-2024 by Vytautas Liuolia.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/falcon/errors.py b/falcon/errors.py
index af64cf0f..092fff2e 100644
--- a/falcon/errors.py
+++ b/falcon/errors.py
@@ -37,14 +37,15 @@ package namespace::
 from __future__ import annotations
 
 from datetime import datetime
-from typing import Iterable, Optional, TYPE_CHECKING, Union
+from typing import Any, Iterable, Optional, TYPE_CHECKING, Union
 
 from falcon.http_error import HTTPError
 import falcon.status_codes as status
+from falcon.util import deprecation
 from falcon.util.misc import dt_to_http
 
 if TYPE_CHECKING:
-    from falcon.typing import HeaderList
+    from falcon._typing import HeaderArg
     from falcon.typing import Headers
 
 
@@ -73,7 +74,7 @@ __all__ = (
     'HTTPNotAcceptable',
     'HTTPNotFound',
     'HTTPNotImplemented',
-    'HTTPPayloadTooLarge',
+    'HTTPContentTooLarge',
     'HTTPPreconditionFailed',
     'HTTPPreconditionRequired',
     'HTTPRangeNotSatisfiable',
@@ -87,6 +88,8 @@ __all__ = (
     'HTTPUnsupportedMediaType',
     'HTTPUriTooLong',
     'HTTPVersionNotSupported',
+    'InvalidMediaRange',
+    'InvalidMediaType',
     'MediaMalformedError',
     'MediaNotFoundError',
     'MediaValidationError',
@@ -110,6 +113,14 @@ class CompatibilityError(ValueError):
     """The given method, value, or type is not compatible."""
 
 
+class InvalidMediaType(ValueError):
+    """The provided media type cannot be parsed into type/subtype."""
+
+
+class InvalidMediaRange(InvalidMediaType):
+    """The media range contains an invalid media type and/or the q value."""
+
+
 class UnsupportedScopeError(RuntimeError):
     """The ASGI scope type is not supported by Falcon."""
 
@@ -143,11 +154,11 @@ class WebSocketDisconnected(ConnectionError):
     Keyword Args:
         code (int): The WebSocket close code, as per the WebSocket spec
             (default ``1000``).
-
-    Attributes:
-        code (int): The WebSocket close code, as per the WebSocket spec.
     """
 
+    code: int
+    """The WebSocket close code, as per the WebSocket spec."""
+
     def __init__(self, code: Optional[int] = None) -> None:
         self.code = code or 1000  # Default to "Normal Closure"
 
@@ -259,7 +270,7 @@ class HTTPBadRequest(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ) -> None:
         super().__init__(
@@ -339,7 +350,7 @@ class HTTPUnauthorized(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         challenges: Optional[Iterable[str]] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
@@ -414,7 +425,7 @@ class HTTPForbidden(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -482,7 +493,7 @@ class HTTPNotFound(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -603,7 +614,7 @@ class HTTPMethodNotAllowed(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         headers = _load_headers(headers)
@@ -671,7 +682,7 @@ class HTTPNotAcceptable(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -741,7 +752,7 @@ class HTTPConflict(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -817,7 +828,7 @@ class HTTPGone(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -878,7 +889,7 @@ class HTTPLengthRequired(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -940,7 +951,7 @@ class HTTPPreconditionFailed(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -952,8 +963,8 @@ class HTTPPreconditionFailed(HTTPError):
         )
 
 
-class HTTPPayloadTooLarge(HTTPError):
-    """413 Payload Too Large.
+class HTTPContentTooLarge(HTTPError):
+    """413 Content Too Large.
 
     The server is refusing to process a request because the request
     payload is larger than the server is willing or able to process.
@@ -1013,7 +1024,7 @@ class HTTPPayloadTooLarge(HTTPError):
         title: Optional[str] = None,
         description: Optional[str] = None,
         retry_after: RetryAfter = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ) -> None:
         super().__init__(
@@ -1025,6 +1036,17 @@ class HTTPPayloadTooLarge(HTTPError):
         )
 
 
+# TODO(vytas): Remove in Falcon 5.0.
+class HTTPPayloadTooLarge(HTTPContentTooLarge):
+    """Compatibility alias of :class:`falcon.HTTPContentTooLarge`."""
+
+    @deprecation.deprecated(
+        'HTTPPayloadTooLarge is deprecated; use HTTPContentTooLarge instead.'
+    )
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        super().__init__(*args, **kwargs)
+
+
 class HTTPUriTooLong(HTTPError):
     """414 URI Too Long.
 
@@ -1080,7 +1102,7 @@ class HTTPUriTooLong(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -1142,7 +1164,7 @@ class HTTPUnsupportedMediaType(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -1218,7 +1240,7 @@ class HTTPRangeNotSatisfiable(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         headers = _load_headers(headers)
@@ -1285,7 +1307,7 @@ class HTTPUnprocessableEntity(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -1344,7 +1366,7 @@ class HTTPLocked(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -1402,7 +1424,7 @@ class HTTPFailedDependency(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -1468,7 +1490,7 @@ class HTTPPreconditionRequired(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -1539,7 +1561,7 @@ class HTTPTooManyRequests(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         retry_after: RetryAfter = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
@@ -1605,7 +1627,7 @@ class HTTPRequestHeaderFieldsTooLarge(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -1677,7 +1699,7 @@ class HTTPUnavailableForLegalReasons(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -1735,7 +1757,7 @@ class HTTPInternalServerError(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -1800,7 +1822,7 @@ class HTTPNotImplemented(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -1858,7 +1880,7 @@ class HTTPBadGateway(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -1932,7 +1954,7 @@ class HTTPServiceUnavailable(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         retry_after: RetryAfter = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
@@ -1992,7 +2014,7 @@ class HTTPGatewayTimeout(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -2056,7 +2078,7 @@ class HTTPVersionNotSupported(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -2118,7 +2140,7 @@ class HTTPInsufficientStorage(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -2177,7 +2199,7 @@ class HTTPLoopDetected(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -2248,7 +2270,7 @@ class HTTPNetworkAuthenticationRequired(HTTPError):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         super().__init__(
@@ -2304,7 +2326,7 @@ class HTTPInvalidHeader(HTTPBadRequest):
         msg: str,
         header_name: str,
         *,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         description = 'The value provided for the "{0}" header is invalid. {1}'
@@ -2360,7 +2382,7 @@ class HTTPMissingHeader(HTTPBadRequest):
         self,
         header_name: str,
         *,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ):
         description = 'The "{0}" header is required.'
@@ -2420,7 +2442,7 @@ class HTTPInvalidParam(HTTPBadRequest):
         msg: str,
         param_name: str,
         *,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ) -> None:
         description = 'The "{0}" parameter is invalid. {1}'
@@ -2478,7 +2500,7 @@ class HTTPMissingParam(HTTPBadRequest):
         self,
         param_name: str,
         *,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ) -> None:
         description = 'The "{0}" parameter is required.'
@@ -2577,7 +2599,7 @@ class MediaMalformedError(HTTPBadRequest):
     """
 
     def __init__(
-        self, media_type: str, **kwargs: Union[HeaderList, HTTPErrorKeywordArguments]
+        self, media_type: str, **kwargs: Union[HeaderArg, HTTPErrorKeywordArguments]
     ):
         super().__init__(
             title='Invalid {0}'.format(media_type),
@@ -2648,7 +2670,7 @@ class MediaValidationError(HTTPBadRequest):
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         **kwargs: HTTPErrorKeywordArguments,
     ) -> None:
         super().__init__(
@@ -2683,7 +2705,7 @@ class MultipartParseError(MediaMalformedError):
         self,
         *,
         description: Optional[str] = None,
-        **kwargs: Union[HeaderList, HTTPErrorKeywordArguments],
+        **kwargs: Union[HeaderArg, HTTPErrorKeywordArguments],
     ) -> None:
         HTTPBadRequest.__init__(
             self,
@@ -2698,7 +2720,7 @@ class MultipartParseError(MediaMalformedError):
 # -----------------------------------------------------------------------------
 
 
-def _load_headers(headers: Optional[HeaderList]) -> Headers:
+def _load_headers(headers: Optional[HeaderArg]) -> Headers:
     """Transform the headers to dict."""
     if headers is None:
         return {}
@@ -2708,9 +2730,9 @@ def _load_headers(headers: Optional[HeaderList]) -> Headers:
 
 
 def _parse_retry_after(
-    headers: Optional[HeaderList],
+    headers: Optional[HeaderArg],
     retry_after: RetryAfter,
-) -> Optional[HeaderList]:
+) -> Optional[HeaderArg]:
     """Set the Retry-After to the headers when required."""
     if retry_after is None:
         return headers
diff --git a/falcon/forwarded.py b/falcon/forwarded.py
index f855b366..e2a4838d 100644
--- a/falcon/forwarded.py
+++ b/falcon/forwarded.py
@@ -55,21 +55,6 @@ class Forwarded:
     """Represents a parsed Forwarded header.
 
     (See also: RFC 7239, Section 4)
-
-    Attributes:
-        src (str): The value of the "for" parameter, or
-            ``None`` if the parameter is absent. Identifies the
-            node making the request to the proxy.
-        dest (str): The value of the "by" parameter, or
-            ``None`` if the parameter is absent. Identifies the
-            client-facing interface of the proxy.
-        host (str): The value of the "host" parameter, or
-            ``None`` if the parameter is absent. Provides the host
-            request header field as received by the proxy.
-        scheme (str): The value of the "proto" parameter, or
-            ``None`` if the parameter is absent. Indicates the
-            protocol that was used to make the request to
-            the proxy.
     """
 
     # NOTE(kgriffs): Use "src" since "for" is a keyword, and
@@ -77,11 +62,32 @@ class Forwarded:
     # falcon.Request interface.
     __slots__ = ('src', 'dest', 'host', 'scheme')
 
+    src: Optional[str]
+    """The value of the "for" parameter, or ``None`` if the parameter is absent.
+
+    Identifies the node making the request to the proxy.
+    """
+    dest: Optional[str]
+    """The value of the "by" parameter, or ``None`` if the parameter is absent.
+
+    Identifies the client-facing interface of the proxy.
+    """
+    host: Optional[str]
+    """The value of the "host" parameter, or ``None`` if the parameter is absent.
+
+    Provides the host request header field as received by the proxy.
+    """
+    scheme: Optional[str]
+    """The value of the "proto" parameter, or ``None`` if the parameter is absent.
+
+    Indicates the protocol that was used to make the request to the proxy.
+    """
+
     def __init__(self) -> None:
-        self.src: Optional[str] = None
-        self.dest: Optional[str] = None
-        self.host: Optional[str] = None
-        self.scheme: Optional[str] = None
+        self.src = None
+        self.dest = None
+        self.host = None
+        self.scheme = None
 
 
 def _parse_forwarded_header(forwarded: str) -> List[Forwarded]:
diff --git a/falcon/hooks.py b/falcon/hooks.py
index 02476675..dc2afbd0 100644
--- a/falcon/hooks.py
+++ b/falcon/hooks.py
@@ -41,13 +41,13 @@ from falcon.util.sync import _wrap_non_coroutine_unsafe
 if TYPE_CHECKING:
     import falcon as wsgi
     from falcon import asgi
-    from falcon.typing import AsgiResponderMethod
-    from falcon.typing import Resource
-    from falcon.typing import Responder
-    from falcon.typing import ResponderMethod
+    from falcon._typing import AsgiResponderMethod
+    from falcon._typing import Resource
+    from falcon._typing import Responder
+    from falcon._typing import ResponderMethod
 
 
-# TODO: if is_async is removed these protocol would no longer be needed, since
+# TODO: when targeting only 3.10+ these protocol would no longer be needed, since
 # ParamSpec could be used together with Concatenate to use a simple Callable
 # to type the before and after functions. This approach was prototyped in
 # https://github.com/falconry/falcon/pull/2234
@@ -109,9 +109,7 @@ _DECORABLE_METHOD_NAME = re.compile(
 )
 
 
-def before(
-    action: BeforeFn, *args: Any, is_async: bool = False, **kwargs: Any
-) -> Callable[[_R], _R]:
+def before(action: BeforeFn, *args: Any, **kwargs: Any) -> Callable[[_R], _R]:
     """Execute the given action function *before* the responder.
 
     The `params` argument that is passed to the hook
@@ -142,21 +140,6 @@ def before(
             and *params* arguments.
 
     Keyword Args:
-        is_async (bool): Set to ``True`` for ASGI apps to provide a hint that
-            the decorated responder is a coroutine function (i.e., that it
-            is defined with ``async def``) or that it returns an awaitable
-            coroutine object.
-
-            Normally, when the function source is declared using ``async def``,
-            the resulting function object is flagged to indicate it returns a
-            coroutine when invoked, and this can be automatically detected.
-            However, it is possible to use a regular function to return an
-            awaitable coroutine object, in which case a hint is required to let
-            the framework know what to expect. Also, a hint is always required
-            when using a cythonized coroutine function, since Cython does not
-            flag them in a way that can be detected in advance, even when the
-            function is declared using ``async def``.
-
         **kwargs: Any additional keyword arguments will be passed through to
             *action*.
     """
@@ -168,9 +151,7 @@ def before(
             ):
                 if _DECORABLE_METHOD_NAME.match(responder_name):
                     responder = cast('Responder', responder)
-                    do_before_all = _wrap_with_before(
-                        responder, action, args, kwargs, is_async
-                    )
+                    do_before_all = _wrap_with_before(responder, action, args, kwargs)
 
                     setattr(responder_or_resource, responder_name, do_before_all)
 
@@ -178,16 +159,14 @@ def before(
 
         else:
             responder = cast('Responder', responder_or_resource)
-            do_before_one = _wrap_with_before(responder, action, args, kwargs, is_async)
+            do_before_one = _wrap_with_before(responder, action, args, kwargs)
 
             return cast(_R, do_before_one)
 
     return _before
 
 
-def after(
-    action: AfterFn, *args: Any, is_async: bool = False, **kwargs: Any
-) -> Callable[[_R], _R]:
+def after(action: AfterFn, *args: Any, **kwargs: Any) -> Callable[[_R], _R]:
     """Execute the given action function *after* the responder.
 
     Args:
@@ -201,21 +180,6 @@ def after(
             arguments.
 
     Keyword Args:
-        is_async (bool): Set to ``True`` for ASGI apps to provide a hint that
-            the decorated responder is a coroutine function (i.e., that it
-            is defined with ``async def``) or that it returns an awaitable
-            coroutine object.
-
-            Normally, when the function source is declared using ``async def``,
-            the resulting function object is flagged to indicate it returns a
-            coroutine when invoked, and this can be automatically detected.
-            However, it is possible to use a regular function to return an
-            awaitable coroutine object, in which case a hint is required to let
-            the framework know what to expect. Also, a hint is always required
-            when using a cythonized coroutine function, since Cython does not
-            flag them in a way that can be detected in advance, even when the
-            function is declared using ``async def``.
-
         **kwargs: Any additional keyword arguments will be passed through to
             *action*.
     """
@@ -227,9 +191,7 @@ def after(
             ):
                 if _DECORABLE_METHOD_NAME.match(responder_name):
                     responder = cast('Responder', responder)
-                    do_after_all = _wrap_with_after(
-                        responder, action, args, kwargs, is_async
-                    )
+                    do_after_all = _wrap_with_after(responder, action, args, kwargs)
 
                     setattr(responder_or_resource, responder_name, do_after_all)
 
@@ -237,7 +199,7 @@ def after(
 
         else:
             responder = cast('Responder', responder_or_resource)
-            do_after_one = _wrap_with_after(responder, action, args, kwargs, is_async)
+            do_after_one = _wrap_with_after(responder, action, args, kwargs)
 
             return cast(_R, do_after_one)
 
@@ -250,11 +212,7 @@ def after(
 
 
 def _wrap_with_after(
-    responder: Responder,
-    action: AfterFn,
-    action_args: Any,
-    action_kwargs: Any,
-    is_async: bool,
+    responder: Responder, action: AfterFn, action_args: Any, action_kwargs: Any
 ) -> Responder:
     """Execute the given action function after a responder method.
 
@@ -264,24 +222,14 @@ def _wrap_with_after(
             method, taking the form ``func(req, resp, resource)``.
         action_args: Additional positional arguments to pass to *action*.
         action_kwargs: Additional keyword arguments to pass to *action*.
-        is_async: Set to ``True`` for cythonized responders that are
-            actually coroutine functions, since such responders can not
-            be auto-detected. A hint is also required for regular functions
-            that happen to return an awaitable coroutine object.
     """
 
     responder_argnames = get_argnames(responder)
     extra_argnames = responder_argnames[2:]  # Skip req, resp
     do_after_responder: Responder
 
-    if is_async or iscoroutinefunction(responder):
-        # NOTE(kgriffs): I manually verified that the implicit "else" branch
-        #   is actually covered, but coverage isn't tracking it for
-        #   some reason.
-        if not is_async:  # pragma: nocover
-            async_action = cast('AsyncAfterFn', _wrap_non_coroutine_unsafe(action))
-        else:
-            async_action = cast('AsyncAfterFn', action)
+    if iscoroutinefunction(responder):
+        async_action = cast('AsyncAfterFn', _wrap_non_coroutine_unsafe(action))
         async_responder = cast('AsgiResponderMethod', responder)
 
         @wraps(async_responder)
@@ -326,7 +274,6 @@ def _wrap_with_before(
     action: BeforeFn,
     action_args: Tuple[Any, ...],
     action_kwargs: Dict[str, Any],
-    is_async: bool,
 ) -> Responder:
     """Execute the given action function before a responder method.
 
@@ -336,24 +283,14 @@ def _wrap_with_before(
             method, taking the form ``func(req, resp, resource, params)``.
         action_args: Additional positional arguments to pass to *action*.
         action_kwargs: Additional keyword arguments to pass to *action*.
-        is_async: Set to ``True`` for cythonized responders that are
-            actually coroutine functions, since such responders can not
-            be auto-detected. A hint is also required for regular functions
-            that happen to return an awaitable coroutine object.
     """
 
     responder_argnames = get_argnames(responder)
     extra_argnames = responder_argnames[2:]  # Skip req, resp
     do_before_responder: Responder
 
-    if is_async or iscoroutinefunction(responder):
-        # NOTE(kgriffs): I manually verified that the implicit "else" branch
-        #   is actually covered, but coverage isn't tracking it for
-        #   some reason.
-        if not is_async:  # pragma: nocover
-            async_action = cast('AsyncBeforeFn', _wrap_non_coroutine_unsafe(action))
-        else:
-            async_action = cast('AsyncBeforeFn', action)
+    if iscoroutinefunction(responder):
+        async_action = cast('AsyncBeforeFn', _wrap_non_coroutine_unsafe(action))
         async_responder = cast('AsgiResponderMethod', responder)
 
         @wraps(async_responder)
diff --git a/falcon/http_error.py b/falcon/http_error.py
index 51e83715..26a6b18f 100644
--- a/falcon/http_error.py
+++ b/falcon/http_error.py
@@ -15,20 +15,19 @@
 
 from __future__ import annotations
 
-from collections import OrderedDict
 from typing import MutableMapping, Optional, Type, TYPE_CHECKING, Union
 import xml.etree.ElementTree as et
 
 from falcon.constants import MEDIA_JSON
-from falcon.util import code_to_http_status
-from falcon.util import http_status_to_code
+from falcon.util import deprecated
+from falcon.util import misc
 from falcon.util import uri
 
 if TYPE_CHECKING:
+    from falcon._typing import HeaderArg
+    from falcon._typing import Link
+    from falcon._typing import ResponseStatus
     from falcon.media import BaseHandler
-    from falcon.typing import HeaderList
-    from falcon.typing import Link
-    from falcon.typing import ResponseStatus
 
 
 class HTTPError(Exception):
@@ -45,8 +44,7 @@ class HTTPError(Exception):
 
     To customize what data is passed to the serializer, subclass
     ``HTTPError`` and override the ``to_dict()`` method (``to_json()``
-    is implemented via ``to_dict()``). To also support XML, override
-    the ``to_xml()`` method.
+    is implemented via ``to_dict()``).
 
     `status` is the only positional argument allowed,
     the other arguments are defined as keyword-only.
@@ -87,21 +85,6 @@ class HTTPError(Exception):
         code (int): An internal code that customers can reference in their
             support request or to help them when searching for knowledge
             base articles related to this error (default ``None``).
-
-    Attributes:
-        status (Union[str,int]): HTTP status code or line (e.g., ``'200 OK'``).
-            This may be set to a member of :class:`http.HTTPStatus`, an HTTP
-            status line string or byte string (e.g., ``'200 OK'``), or an
-            ``int``.
-        status_code (int): HTTP status code normalized from the ``status``
-            argument passed to the initializer.
-        title (str): Error title to send to the client.
-        description (str): Description of the error to send to the client.
-        headers (dict): Extra headers to add to the response.
-        link (str): An href that the client can provide to the user for
-            getting help.
-        code (int): An internal application code that a user can reference when
-            requesting support for the error.
     """
 
     __slots__ = (
@@ -113,13 +96,35 @@ class HTTPError(Exception):
         'code',
     )
 
+    status: ResponseStatus
+    """HTTP status code or line (e.g., ``'200 OK'``).
+
+    This may be set to a member of :class:`http.HTTPStatus`, an HTTP
+    status line string or byte string (e.g., ``'200 OK'``), or an ``int``.
+    """
+    title: str
+    """Error title to send to the client.
+
+    Derived from the ``status`` if not provided.
+    """
+    description: Optional[str]
+    """Description of the error to send to the client."""
+    headers: Optional[HeaderArg]
+    """Extra headers to add to the response."""
+    link: Optional[Link]
+    """An href that the client can provide to the user for getting help."""
+    code: Optional[int]
+    """An internal application code that a user can reference when requesting
+    support for the error.
+    """
+
     def __init__(
         self,
         status: ResponseStatus,
         *,
         title: Optional[str] = None,
         description: Optional[str] = None,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         href: Optional[str] = None,
         href_text: Optional[str] = None,
         code: Optional[int] = None,
@@ -130,18 +135,18 @@ class HTTPError(Exception):
         #   we'll probably switch over to making everything code-based to more
         #   easily support HTTP/2. When that happens, should we continue to
         #   include the reason phrase in the title?
-        self.title = title or code_to_http_status(status)
+        self.title = title or misc.code_to_http_status(status)
 
         self.description = description
         self.headers = headers
         self.code = code
-        self.link: Optional[Link]
 
         if href:
-            link = self.link = OrderedDict()
-            link['text'] = href_text or 'Documentation related to this error'
-            link['href'] = uri.encode(href)
-            link['rel'] = 'help'
+            self.link = {
+                'text': href_text or 'Documentation related to this error',
+                'href': uri.encode(href),
+                'rel': 'help',
+            }
         else:
             self.link = None
 
@@ -152,7 +157,10 @@ class HTTPError(Exception):
 
     @property
     def status_code(self) -> int:
-        return http_status_to_code(self.status)
+        """HTTP status code normalized from the ``status`` argument passed
+        to the initializer.
+        """  # noqa: D205
+        return misc.http_status_to_code(self.status)
 
     def to_dict(
         self, obj_type: Type[MutableMapping[str, Union[str, int, None, Link]]] = dict
@@ -200,18 +208,14 @@ class HTTPError(Exception):
 
         """
 
-        obj = self.to_dict(OrderedDict)
+        obj = self.to_dict()
         if handler is None:
             handler = _DEFAULT_JSON_HANDLER
+        # NOTE: the json handler requires the sync serialize interface
         return handler.serialize(obj, MEDIA_JSON)
 
-    def to_xml(self) -> bytes:
-        """Return an XML-encoded representation of the error.
-
-        Returns:
-            bytes: An XML document for the error.
-
-        """
+    def _to_xml(self) -> bytes:
+        """Return an XML-encoded representation of the error."""
 
         error_element = et.Element('error')
 
@@ -233,6 +237,18 @@ class HTTPError(Exception):
             error_element, encoding='utf-8'
         )
 
+    @deprecated(
+        'The internal serialization to xml is deprecated. A similar output can be '
+        'obtained by serializing to xml the output of ``.to_dict()``'
+    )
+    def to_xml(self) -> bytes:
+        """Return an XML-encoded representation of the error.
+
+        Returns:
+            bytes: An XML document for the error.
+        """
+        return self._to_xml()
+
 
 # NOTE: initialized in falcon.media.json, that is always imported since Request/Response
 # are imported by falcon init.
diff --git a/falcon/http_status.py b/falcon/http_status.py
index dd552277..306519c9 100644
--- a/falcon/http_status.py
+++ b/falcon/http_status.py
@@ -20,8 +20,8 @@ from typing import Optional, TYPE_CHECKING
 from falcon.util import http_status_to_code
 
 if TYPE_CHECKING:
-    from falcon.typing import HeaderList
-    from falcon.typing import ResponseStatus
+    from falcon._typing import HeaderArg
+    from falcon._typing import ResponseStatus
 
 
 class HTTPStatus(Exception):
@@ -47,7 +47,7 @@ class HTTPStatus(Exception):
     """The HTTP status line or integer code for the status that this exception
     represents.
     """
-    headers: Optional[HeaderList]
+    headers: Optional[HeaderArg]
     """Extra headers to add to the response."""
     text: Optional[str]
     """String representing response content.
@@ -57,7 +57,7 @@ class HTTPStatus(Exception):
     def __init__(
         self,
         status: ResponseStatus,
-        headers: Optional[HeaderList] = None,
+        headers: Optional[HeaderArg] = None,
         text: Optional[str] = None,
     ) -> None:
         self.status = status
diff --git a/falcon/inspect.py b/falcon/inspect.py
index 6d221f71..00b840d6 100644
--- a/falcon/inspect.py
+++ b/falcon/inspect.py
@@ -300,13 +300,16 @@ class RouteMethodInfo(_Traversable):
         internal (bool): Whether or not this was a default responder added
             by the framework.
 
-    Attributes:
-        suffix (str): The suffix of this route function. This is set to an empty
-            string when the function has no suffix.
     """
 
     __visit_name__ = 'route_method'
 
+    suffix: str
+    """The suffix of this route function.
+
+    This is set to an empty string when the function has no suffix.
+    """
+
     def __init__(
         self, method: str, source_info: str, function_name: str, internal: bool
     ):
@@ -490,12 +493,13 @@ class MiddlewareInfo(_Traversable):
         independent (bool): Whether or not the middleware components are executed
             independently.
 
-    Attributes:
-        independent_text (str): Text created from the `independent` arg.
     """
 
     __visit_name__ = 'middleware'
 
+    independent_text: str
+    """Text created from the `independent` arg."""
+
     def __init__(
         self,
         middleware_tree: MiddlewareTreeInfo,
diff --git a/falcon/media/base.py b/falcon/media/base.py
index 70ceea77..0d80611f 100644
--- a/falcon/media/base.py
+++ b/falcon/media/base.py
@@ -4,11 +4,11 @@ import abc
 import io
 from typing import Optional, Union
 
+from falcon._typing import DeserializeSync
+from falcon._typing import SerializeSync
 from falcon.constants import MEDIA_JSON
 from falcon.typing import AsyncReadableIO
-from falcon.typing import DeserializeSync
 from falcon.typing import ReadableIO
-from falcon.typing import SerializeSync
 
 
 class BaseHandler(metaclass=abc.ABCMeta):
diff --git a/falcon/media/handlers.py b/falcon/media/handlers.py
index 8c1965f0..b48a95d7 100644
--- a/falcon/media/handlers.py
+++ b/falcon/media/handlers.py
@@ -18,6 +18,8 @@ from typing import (
 )
 
 from falcon import errors
+from falcon._typing import DeserializeSync
+from falcon._typing import SerializeSync
 from falcon.constants import MEDIA_JSON
 from falcon.constants import MEDIA_MULTIPART
 from falcon.constants import MEDIA_URLENCODED
@@ -28,10 +30,8 @@ from falcon.media.json import JSONHandler
 from falcon.media.multipart import MultipartFormHandler
 from falcon.media.multipart import MultipartParseOptions
 from falcon.media.urlencoded import URLEncodedFormHandler
-from falcon.typing import DeserializeSync
-from falcon.typing import SerializeSync
+from falcon.util import mediatypes
 from falcon.util import misc
-from falcon.vendor import mimeparse
 
 
 class MissingDependencyHandler(BinaryBaseHandlerWS):
@@ -186,7 +186,7 @@ def _best_match(media_type: str, all_media_types: Sequence[str]) -> Optional[str
     try:
         # NOTE(jmvrbanac): Mimeparse will return an empty string if it can
         # parse the media type, but cannot find a suitable type.
-        result = mimeparse.best_match(all_media_types, media_type)
+        result = mediatypes.best_match(all_media_types, media_type)
     except ValueError:
         pass
 
diff --git a/falcon/media/multipart.py b/falcon/media/multipart.py
index 340a5aea..4cf1b24e 100644
--- a/falcon/media/multipart.py
+++ b/falcon/media/multipart.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2023 by Vytautas Liuolia.
+# Copyright 2019-2024 by Vytautas Liuolia.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -33,12 +33,12 @@ from typing import (
 from urllib.parse import unquote_to_bytes
 
 from falcon import errors
+from falcon._typing import _UNSET
+from falcon._typing import UnsetOr
 from falcon.errors import MultipartParseError
 from falcon.media.base import BaseHandler
 from falcon.stream import BoundedStream
 from falcon.typing import AsyncReadableIO
-from falcon.typing import MISSING
-from falcon.typing import MissingOr
 from falcon.typing import ReadableIO
 from falcon.util import BufferedReader
 from falcon.util import misc
@@ -80,9 +80,9 @@ class BodyPart:
 
     _content_disposition: Optional[Tuple[str, Dict[str, str]]] = None
     _data: Optional[bytes] = None
-    _filename: MissingOr[Optional[str]] = MISSING
-    _media: MissingOr[Any] = MISSING
-    _name: MissingOr[Optional[str]] = MISSING
+    _filename: UnsetOr[Optional[str]] = _UNSET
+    _media: UnsetOr[Any] = _UNSET
+    _name: UnsetOr[Optional[str]] = _UNSET
 
     stream: PyBufferedReader
     """File-like input object for reading the body part of the
@@ -198,7 +198,7 @@ class BodyPart:
     @property
     def filename(self) -> Optional[str]:
         """File name if the body part is an attached file, and ``None`` otherwise."""
-        if self._filename is MISSING:
+        if self._filename is _UNSET:
             if self._content_disposition is None:
                 value = self._headers.get(b'content-disposition', b'')
                 self._content_disposition = parse_header(value.decode())
@@ -233,7 +233,7 @@ class BodyPart:
         See also: :func:`~.secure_filename`
         """  # noqa: D205
         try:
-            return misc.secure_filename(self.filename)
+            return misc.secure_filename(self.filename or '')
         except ValueError as ex:
             raise MultipartParseError(description=str(ex)) from ex
 
@@ -253,7 +253,7 @@ class BodyPart:
             However, Falcon will not raise any error if this parameter is
             missing; the property value will be ``None`` in that case.
         """
-        if self._name is MISSING:
+        if self._name is _UNSET:
             if self._content_disposition is None:
                 value = self._headers.get(b'content-disposition', b'')
                 self._content_disposition = parse_header(value.decode())
@@ -277,7 +277,7 @@ class BodyPart:
         Returns:
             object: The deserialized media representation.
         """
-        if self._media is MISSING:
+        if self._media is _UNSET:
             handler, _, _ = self._parse_options.media_handlers._resolve(
                 self.content_type, 'text/plain'
             )
diff --git a/falcon/media/validators/jsonschema.py b/falcon/media/validators/jsonschema.py
index 0e6f14b6..69be0634 100644
--- a/falcon/media/validators/jsonschema.py
+++ b/falcon/media/validators/jsonschema.py
@@ -1,5 +1,8 @@
+from __future__ import annotations
+
 from functools import wraps
 from inspect import iscoroutinefunction
+from typing import Any, Callable, Dict, Optional, TYPE_CHECKING
 
 import falcon
 
@@ -8,8 +11,17 @@ try:
 except ImportError:  # pragma: nocover
     pass
 
+if TYPE_CHECKING:
+    import falcon as wsgi
+    from falcon import asgi
+
+Schema = Optional[Dict[str, Any]]
+ResponderMethod = Callable[..., Any]
 
-def validate(req_schema=None, resp_schema=None, is_async=False):
+
+def validate(
+    req_schema: Schema = None, resp_schema: Schema = None
+) -> Callable[[ResponderMethod], ResponderMethod]:
     """Validate ``req.media`` using JSON Schema.
 
     This decorator provides standard JSON Schema validation via the
@@ -39,20 +51,6 @@ def validate(req_schema=None, resp_schema=None, is_async=False):
         resp_schema (dict): A dictionary that follows the JSON
             Schema specification. The response will be validated against this
             schema.
-        is_async (bool): Set to ``True`` for ASGI apps to provide a hint that
-            the decorated responder is a coroutine function (i.e., that it
-            is defined with ``async def``) or that it returns an awaitable
-            coroutine object.
-
-            Normally, when the function source is declared using ``async def``,
-            the resulting function object is flagged to indicate it returns a
-            coroutine when invoked, and this can be automatically detected.
-            However, it is possible to use a regular function to return an
-            awaitable coroutine object, in which case a hint is required to let
-            the framework know what to expect. Also, a hint is always required
-            when using a cythonized coroutine function, since Cython does not
-            flag them in a way that can be detected in advance, even when the
-            function is declared using ``async def``.
 
     Example:
 
@@ -84,23 +82,10 @@ def validate(req_schema=None, resp_schema=None, is_async=False):
 
                     # -- snip --
 
-            .. tab-item:: ASGI (Cythonized App)
-
-                .. code:: python
-
-                    from falcon.media.validators import jsonschema
-
-                    # -- snip --
-
-                    @jsonschema.validate(my_post_schema, is_async=True)
-                    async def on_post(self, req, resp):
-
-                    # -- snip --
-
     """
 
-    def decorator(func):
-        if iscoroutinefunction(func) or is_async:
+    def decorator(func: ResponderMethod) -> ResponderMethod:
+        if iscoroutinefunction(func):
             return _validate_async(func, req_schema, resp_schema)
 
         return _validate(func, req_schema, resp_schema)
@@ -108,9 +93,13 @@ def validate(req_schema=None, resp_schema=None, is_async=False):
     return decorator
 
 
-def _validate(func, req_schema=None, resp_schema=None):
+def _validate(
+    func: ResponderMethod, req_schema: Schema = None, resp_schema: Schema = None
+) -> ResponderMethod:
     @wraps(func)
-    def wrapper(self, req, resp, *args, **kwargs):
+    def wrapper(
+        self: Any, req: wsgi.Request, resp: wsgi.Response, *args: Any, **kwargs: Any
+    ) -> Any:
         if req_schema is not None:
             try:
                 jsonschema.validate(
@@ -141,9 +130,13 @@ def _validate(func, req_schema=None, resp_schema=None):
     return wrapper
 
 
-def _validate_async(func, req_schema=None, resp_schema=None):
+def _validate_async(
+    func: ResponderMethod, req_schema: Schema = None, resp_schema: Schema = None
+) -> ResponderMethod:
     @wraps(func)
-    async def wrapper(self, req, resp, *args, **kwargs):
+    async def wrapper(
+        self: Any, req: asgi.Request, resp: asgi.Response, *args: Any, **kwargs: Any
+    ) -> Any:
         if req_schema is not None:
             m = await req.get_media()
 
diff --git a/falcon/request.py b/falcon/request.py
index 6c4fd8b4..64f9312e 100644
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -22,12 +22,12 @@ from typing import (
     Callable,
     ClassVar,
     Dict,
+    Iterable,
     List,
     Literal,
     Mapping,
     Optional,
     overload,
-    Sequence,
     TextIO,
     Tuple,
     Type,
@@ -40,6 +40,9 @@ import warnings
 from falcon import errors
 from falcon import request_helpers as helpers
 from falcon import util
+from falcon._typing import _UNSET
+from falcon._typing import StoreArg
+from falcon._typing import UnsetOr
 from falcon.constants import DEFAULT_MEDIA_TYPE
 from falcon.constants import MEDIA_JSON
 from falcon.forwarded import _parse_forwarded_header
@@ -47,16 +50,13 @@ from falcon.forwarded import Forwarded
 from falcon.media import Handlers
 from falcon.media.json import _DEFAULT_JSON_HANDLER
 from falcon.stream import BoundedStream
-from falcon.typing import MISSING
-from falcon.typing import MissingOr
 from falcon.typing import ReadableIO
-from falcon.typing import StoreArgument
 from falcon.util import deprecation
 from falcon.util import ETag
+from falcon.util import mediatypes
 from falcon.util import structures
 from falcon.util.uri import parse_host
 from falcon.util.uri import parse_query_string
-from falcon.vendor import mimeparse
 
 DEFAULT_ERROR_LOG_FORMAT = '{0:%Y-%m-%d %H:%M:%S} [FALCON] [ERROR] {1} {2}{3} => '
 
@@ -114,10 +114,8 @@ class Request:
     )
     _cookies: Optional[Dict[str, List[str]]] = None
     _cookies_collapsed: Optional[Dict[str, str]] = None
-    _cached_if_match: MissingOr[Optional[List[Union[ETag, Literal['*']]]]] = MISSING
-    _cached_if_none_match: MissingOr[Optional[List[Union[ETag, Literal['*']]]]] = (
-        MISSING
-    )
+    _cached_if_match: UnsetOr[Optional[List[Union[ETag, Literal['*']]]]] = _UNSET
+    _cached_if_none_match: UnsetOr[Optional[List[Union[ETag, Literal['*']]]]] = _UNSET
 
     # Child classes may override this
     context_type: ClassVar[Type[structures.Context]] = structures.Context
@@ -246,13 +244,13 @@ class Request:
         self.is_websocket: bool = False
 
         self.env = env
-        self.options = options if options else RequestOptions()
+        self.options = options if options is not None else RequestOptions()
 
         self._wsgierrors: TextIO = env['wsgi.errors']
         self.method = env['REQUEST_METHOD']
 
         self.uri_template = None
-        self._media: MissingOr[Any] = MISSING
+        self._media: UnsetOr[Any] = _UNSET
         self._media_error: Optional[Exception] = None
 
         # NOTE(kgriffs): PEP 3333 specifies that PATH_INFO may be the
@@ -345,15 +343,15 @@ class Request:
     # Properties
     # ------------------------------------------------------------------------
 
-    user_agent: Optional[str] = helpers.header_property('HTTP_USER_AGENT')
+    user_agent: Optional[str] = helpers._header_property('HTTP_USER_AGENT')
     """Value of the User-Agent header, or ``None`` if the header is missing."""
-    auth: Optional[str] = helpers.header_property('HTTP_AUTHORIZATION')
+    auth: Optional[str] = helpers._header_property('HTTP_AUTHORIZATION')
     """Value of the Authorization header, or ``None`` if the header is missing."""
-    expect: Optional[str] = helpers.header_property('HTTP_EXPECT')
+    expect: Optional[str] = helpers._header_property('HTTP_EXPECT')
     """Value of the Expect header, or ``None`` if the header is missing."""
-    if_range: Optional[str] = helpers.header_property('HTTP_IF_RANGE')
+    if_range: Optional[str] = helpers._header_property('HTTP_IF_RANGE')
     """Value of the If-Range header, or ``None`` if the header is missing."""
-    referer: Optional[str] = helpers.header_property('HTTP_REFERER')
+    referer: Optional[str] = helpers._header_property('HTTP_REFERER')
     """Value of the Referer header, or ``None`` if the header is missing."""
 
     @property
@@ -492,7 +490,7 @@ class Request:
         # TODO(kgriffs): It may make sense at some point to create a
         #   header property generator that DRY's up the memoization
         #   pattern for us.
-        if self._cached_if_match is MISSING:
+        if self._cached_if_match is _UNSET:
             header_value = self.env.get('HTTP_IF_MATCH')
             if header_value:
                 self._cached_if_match = helpers._parse_etags(header_value)
@@ -513,7 +511,7 @@ class Request:
 
         (See also: RFC 7232, Section 3.2)
         """  # noqa: D205
-        if self._cached_if_none_match is MISSING:
+        if self._cached_if_none_match is _UNSET:
             header_value = self.env.get('HTTP_IF_NONE_MATCH')
             if header_value:
                 self._cached_if_none_match = helpers._parse_etags(header_value)
@@ -921,7 +919,7 @@ class Request:
             if self._cookies is None:
                 header_value = self.get_header('Cookie')
                 if header_value:
-                    self._cookies = helpers.parse_cookie_header(header_value)
+                    self._cookies = helpers._parse_cookie_header(header_value)
                 else:
                     self._cookies = {}
 
@@ -1062,7 +1060,7 @@ class Request:
 
         return netloc_value
 
-    def get_media(self, default_when_empty: MissingOr[Any] = MISSING) -> Any:
+    def get_media(self, default_when_empty: UnsetOr[Any] = _UNSET) -> Any:
         """Return a deserialized form of the request stream.
 
         The first time this method is called, the request stream will be
@@ -1103,10 +1101,10 @@ class Request:
         Returns:
             media (object): The deserialized media representation.
         """
-        if self._media is not MISSING:
+        if self._media is not _UNSET:
             return self._media
         if self._media_error is not None:
-            if default_when_empty is not MISSING and isinstance(
+            if default_when_empty is not _UNSET and isinstance(
                 self._media_error, errors.MediaNotFoundError
             ):
                 return default_when_empty
@@ -1122,7 +1120,7 @@ class Request:
             )
         except errors.MediaNotFoundError as err:
             self._media_error = err
-            if default_when_empty is not MISSING:
+            if default_when_empty is not _UNSET:
                 return default_when_empty
             raise
         except Exception as err:
@@ -1169,11 +1167,11 @@ class Request:
 
         # Fall back to full-blown parsing
         try:
-            return mimeparse.quality(media_type, accept) != 0.0
+            return mediatypes.quality(media_type, accept) != 0.0
         except ValueError:
             return False
 
-    def client_prefers(self, media_types: Sequence[str]) -> Optional[str]:
+    def client_prefers(self, media_types: Iterable[str]) -> Optional[str]:
         """Return the client's preferred media type, given several choices.
 
         Args:
@@ -1189,7 +1187,7 @@ class Request:
 
         try:
             # NOTE(kgriffs): best_match will return '' if no match is found
-            preferred_type = mimeparse.best_match(media_types, self.accept)
+            preferred_type = mediatypes.best_match(media_types, self.accept)
         except ValueError:
             # Value for the accept header was not formatted correctly
             preferred_type = ''
@@ -1365,7 +1363,7 @@ class Request:
             # point.
             header_value = self.get_header('Cookie')
             if header_value:
-                self._cookies = helpers.parse_cookie_header(header_value)
+                self._cookies = helpers._parse_cookie_header(header_value)
             else:
                 self._cookies = {}
 
@@ -1376,7 +1374,7 @@ class Request:
         self,
         name: str,
         required: Literal[True],
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         default: Optional[str] = ...,
     ) -> str: ...
 
@@ -1385,7 +1383,7 @@ class Request:
         self,
         name: str,
         required: bool = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         *,
         default: str,
     ) -> str: ...
@@ -1395,7 +1393,7 @@ class Request:
         self,
         name: str,
         required: bool = False,
-        store: StoreArgument = None,
+        store: StoreArg = None,
         default: Optional[str] = None,
     ) -> Optional[str]: ...
 
@@ -1403,7 +1401,7 @@ class Request:
         self,
         name: str,
         required: bool = False,
-        store: StoreArgument = None,
+        store: StoreArg = None,
         default: Optional[str] = None,
     ) -> Optional[str]:
         """Return the raw value of a query string parameter as a string.
@@ -1488,7 +1486,7 @@ class Request:
         required: Literal[True],
         min_value: Optional[int] = ...,
         max_value: Optional[int] = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         default: Optional[int] = ...,
     ) -> int: ...
 
@@ -1499,7 +1497,7 @@ class Request:
         required: bool = ...,
         min_value: Optional[int] = ...,
         max_value: Optional[int] = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         *,
         default: int,
     ) -> int: ...
@@ -1511,7 +1509,7 @@ class Request:
         required: bool = ...,
         min_value: Optional[int] = ...,
         max_value: Optional[int] = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         default: Optional[int] = ...,
     ) -> Optional[int]: ...
 
@@ -1521,7 +1519,7 @@ class Request:
         required: bool = False,
         min_value: Optional[int] = None,
         max_value: Optional[int] = None,
-        store: StoreArgument = None,
+        store: StoreArg = None,
         default: Optional[int] = None,
     ) -> Optional[int]:
         """Return the value of a query string parameter as an int.
@@ -1601,7 +1599,7 @@ class Request:
         required: Literal[True],
         min_value: Optional[float] = ...,
         max_value: Optional[float] = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         default: Optional[float] = ...,
     ) -> float: ...
 
@@ -1612,7 +1610,7 @@ class Request:
         required: bool = ...,
         min_value: Optional[float] = ...,
         max_value: Optional[float] = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         *,
         default: float,
     ) -> float: ...
@@ -1624,7 +1622,7 @@ class Request:
         required: bool = ...,
         min_value: Optional[float] = ...,
         max_value: Optional[float] = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         default: Optional[float] = ...,
     ) -> Optional[float]: ...
 
@@ -1634,7 +1632,7 @@ class Request:
         required: bool = False,
         min_value: Optional[float] = None,
         max_value: Optional[float] = None,
-        store: StoreArgument = None,
+        store: StoreArg = None,
         default: Optional[float] = None,
     ) -> Optional[float]:
         """Return the value of a query string parameter as an float.
@@ -1712,7 +1710,7 @@ class Request:
         self,
         name: str,
         required: Literal[True],
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         default: Optional[UUID] = ...,
     ) -> UUID: ...
 
@@ -1721,7 +1719,7 @@ class Request:
         self,
         name: str,
         required: bool = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         *,
         default: UUID,
     ) -> UUID: ...
@@ -1731,7 +1729,7 @@ class Request:
         self,
         name: str,
         required: bool = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         default: Optional[UUID] = ...,
     ) -> Optional[UUID]: ...
 
@@ -1739,7 +1737,7 @@ class Request:
         self,
         name: str,
         required: bool = False,
-        store: StoreArgument = None,
+        store: StoreArg = None,
         default: Optional[UUID] = None,
     ) -> Optional[UUID]:
         """Return the value of a query string parameter as an UUID.
@@ -1812,7 +1810,7 @@ class Request:
         self,
         name: str,
         required: Literal[True],
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         blank_as_true: bool = ...,
         default: Optional[bool] = ...,
     ) -> bool: ...
@@ -1822,7 +1820,7 @@ class Request:
         self,
         name: str,
         required: bool = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         blank_as_true: bool = ...,
         *,
         default: bool,
@@ -1833,7 +1831,7 @@ class Request:
         self,
         name: str,
         required: bool = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         blank_as_true: bool = ...,
         default: Optional[bool] = ...,
     ) -> Optional[bool]: ...
@@ -1842,7 +1840,7 @@ class Request:
         self,
         name: str,
         required: bool = False,
-        store: StoreArgument = None,
+        store: StoreArg = None,
         blank_as_true: bool = True,
         default: Optional[bool] = None,
     ) -> Optional[bool]:
@@ -1926,7 +1924,7 @@ class Request:
         transform: None = ...,
         *,
         required: Literal[True],
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         default: Optional[List[str]] = ...,
     ) -> List[str]: ...
 
@@ -1936,7 +1934,7 @@ class Request:
         name: str,
         transform: Callable[[str], _T],
         required: Literal[True],
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         default: Optional[List[_T]] = ...,
     ) -> List[_T]: ...
 
@@ -1946,7 +1944,7 @@ class Request:
         name: str,
         transform: None = ...,
         required: bool = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         *,
         default: List[str],
     ) -> List[str]: ...
@@ -1957,7 +1955,7 @@ class Request:
         name: str,
         transform: Callable[[str], _T],
         required: bool = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         *,
         default: List[_T],
     ) -> List[_T]: ...
@@ -1968,7 +1966,7 @@ class Request:
         name: str,
         transform: None = ...,
         required: bool = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         default: Optional[List[str]] = ...,
     ) -> Optional[List[str]]: ...
 
@@ -1978,7 +1976,7 @@ class Request:
         name: str,
         transform: Callable[[str], _T],
         required: bool = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         default: Optional[List[_T]] = ...,
     ) -> Optional[List[_T]]: ...
 
@@ -1987,7 +1985,7 @@ class Request:
         name: str,
         transform: Optional[Callable[[str], _T]] = None,
         required: bool = False,
-        store: StoreArgument = None,
+        store: StoreArg = None,
         default: Optional[List[_T]] = None,
     ) -> Optional[List[_T] | List[str]]:
         """Return the value of a query string parameter as a list.
@@ -2087,7 +2085,7 @@ class Request:
         format_string: str = ...,
         *,
         required: Literal[True],
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         default: Optional[datetime] = ...,
     ) -> datetime: ...
 
@@ -2097,7 +2095,7 @@ class Request:
         name: str,
         format_string: str = ...,
         required: bool = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         *,
         default: datetime,
     ) -> datetime: ...
@@ -2108,7 +2106,7 @@ class Request:
         name: str,
         format_string: str = ...,
         required: bool = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         default: Optional[datetime] = ...,
     ) -> Optional[datetime]: ...
 
@@ -2117,7 +2115,7 @@ class Request:
         name: str,
         format_string: str = '%Y-%m-%dT%H:%M:%SZ',
         required: bool = False,
-        store: StoreArgument = None,
+        store: StoreArg = None,
         default: Optional[datetime] = None,
     ) -> Optional[datetime]:
         """Return the value of a query string parameter as a datetime.
@@ -2171,7 +2169,7 @@ class Request:
         format_string: str = ...,
         *,
         required: Literal[True],
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         default: Optional[py_date] = ...,
     ) -> py_date: ...
 
@@ -2181,7 +2179,7 @@ class Request:
         name: str,
         format_string: str = ...,
         required: bool = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         *,
         default: py_date,
     ) -> py_date: ...
@@ -2192,7 +2190,7 @@ class Request:
         name: str,
         format_string: str = ...,
         required: bool = ...,
-        store: StoreArgument = ...,
+        store: StoreArg = ...,
         default: Optional[py_date] = ...,
     ) -> Optional[py_date]: ...
 
@@ -2201,7 +2199,7 @@ class Request:
         name: str,
         format_string: str = '%Y-%m-%d',
         required: bool = False,
-        store: StoreArgument = None,
+        store: StoreArg = None,
         default: Optional[py_date] = None,
     ) -> Optional[py_date]:
         """Return the value of a query string parameter as a date.
@@ -2247,7 +2245,7 @@ class Request:
         self,
         name: str,
         required: bool = False,
-        store: StoreArgument = None,
+        store: StoreArg = None,
         default: Optional[Any] = None,
     ) -> Any:
         """Return the decoded JSON value of a query string parameter.
diff --git a/falcon/request_helpers.py b/falcon/request_helpers.py
index 7cab2116..7d8cc9ae 100644
--- a/falcon/request_helpers.py
+++ b/falcon/request_helpers.py
@@ -20,10 +20,6 @@ from http import cookies as http_cookies
 import re
 from typing import Any, Dict, List, Literal, Optional, TYPE_CHECKING, Union
 
-# TODO: Body, BoundedStream import here is for backwards-compatibility
-# and it should be removed in Falcon 4.0
-from falcon.stream import Body  # NOQA
-from falcon.stream import BoundedStream  # NOQA
 from falcon.util import ETag
 
 if TYPE_CHECKING:
@@ -46,7 +42,7 @@ _COOKIE_NAME_RESERVED_CHARS = re.compile(
 _ENTITY_TAG_PATTERN = re.compile(r'([Ww]/)?"([^"]*)"')
 
 
-def parse_cookie_header(header_value: str) -> Dict[str, List[str]]:
+def _parse_cookie_header(header_value: str) -> Dict[str, List[str]]:
     """Parse a Cookie header value into a dict of named values.
 
     (See also: RFC 6265, Section 5.4)
@@ -107,7 +103,7 @@ def parse_cookie_header(header_value: str) -> Dict[str, List[str]]:
     return cookies
 
 
-def header_property(wsgi_name: str) -> Any:
+def _header_property(wsgi_name: str) -> Any:
     """Create a read-only header property.
 
     Args:
diff --git a/falcon/responders.py b/falcon/responders.py
index b28277b4..a1efa722 100644
--- a/falcon/responders.py
+++ b/falcon/responders.py
@@ -18,12 +18,12 @@ from __future__ import annotations
 
 from typing import Any, Iterable, NoReturn, TYPE_CHECKING, Union
 
+from falcon._typing import AsgiResponderCallable
+from falcon._typing import ResponderCallable
 from falcon.errors import HTTPBadRequest
 from falcon.errors import HTTPMethodNotAllowed
 from falcon.errors import HTTPRouteNotFound
 from falcon.status_codes import HTTP_200
-from falcon.typing import AsgiResponderCallable
-from falcon.typing import ResponderCallable
 
 if TYPE_CHECKING:
     from falcon import Request
diff --git a/falcon/response.py b/falcon/response.py
index a8575970..afcc8941 100644
--- a/falcon/response.py
+++ b/falcon/response.py
@@ -36,20 +36,20 @@ from typing import (
     Union,
 )
 
+from falcon._typing import _UNSET
+from falcon._typing import RangeSetHeader
+from falcon._typing import UnsetOr
 from falcon.constants import _DEFAULT_STATIC_MEDIA_TYPES
 from falcon.constants import DEFAULT_MEDIA_TYPE
 from falcon.errors import HeaderNotSupported
 from falcon.media import Handlers
-from falcon.response_helpers import format_content_disposition
-from falcon.response_helpers import format_etag_header
-from falcon.response_helpers import format_header_value_list
-from falcon.response_helpers import format_range
-from falcon.response_helpers import header_property
-from falcon.response_helpers import is_ascii_encodable
+from falcon.response_helpers import _format_content_disposition
+from falcon.response_helpers import _format_etag_header
+from falcon.response_helpers import _format_header_value_list
+from falcon.response_helpers import _format_range
+from falcon.response_helpers import _header_property
+from falcon.response_helpers import _is_ascii_encodable
 from falcon.typing import Headers
-from falcon.typing import MISSING
-from falcon.typing import MissingOr
-from falcon.typing import RangeSetHeader
 from falcon.typing import ReadableIO
 from falcon.util import dt_to_http
 from falcon.util import http_cookies
@@ -97,7 +97,7 @@ class Response:
     _extra_headers: Optional[List[Tuple[str, str]]]
     _headers: Headers
     _media: Optional[Any]
-    _media_rendered: MissingOr[bytes]
+    _media_rendered: UnsetOr[bytes]
 
     # Child classes may override this
     context_type: ClassVar[Type[structures.Context]] = structures.Context
@@ -178,7 +178,7 @@ class Response:
         #   only instantiating the list object later on IFF it is needed.
         self._extra_headers = None
 
-        self.options = options if options else ResponseOptions()
+        self.options = options if options is not None else ResponseOptions()
 
         # NOTE(tbug): will be set to a SimpleCookie object
         # when cookie is set via set_cookie
@@ -188,7 +188,7 @@ class Response:
         self.stream = None
         self._data = None
         self._media = None
-        self._media_rendered = MISSING
+        self._media_rendered = _UNSET
 
         self.context = self.context_type()
 
@@ -251,7 +251,7 @@ class Response:
     @media.setter
     def media(self, value: Any) -> None:
         self._media = value
-        self._media_rendered = MISSING
+        self._media_rendered = _UNSET
 
     def render_body(self) -> Optional[bytes]:
         """Get the raw bytestring content for the response body.
@@ -276,9 +276,9 @@ class Response:
             data = self._data
 
             if data is None and self._media is not None:
-                # NOTE(kgriffs): We use a special MISSING singleton since
+                # NOTE(kgriffs): We use a special _UNSET singleton since
                 #   None is ambiguous (the media handler might return None).
-                if self._media_rendered is MISSING:
+                if self._media_rendered is _UNSET:
                     if not self.content_type:
                         self.content_type = self.options.default_media_type
 
@@ -471,9 +471,9 @@ class Response:
 
         """
 
-        if not is_ascii_encodable(name):
+        if not _is_ascii_encodable(name):
             raise KeyError('name is not ascii encodable')
-        if not is_ascii_encodable(value):
+        if not _is_ascii_encodable(value):
             raise ValueError('value is not ascii encodable')
 
         value = str(value)
@@ -975,7 +975,7 @@ class Response:
             'Please use append_link() instead.'
         )
 
-    cache_control: Union[str, Iterable[str], None] = header_property(
+    cache_control: Union[str, Iterable[str], None] = _header_property(
         'Cache-Control',
         """Set the Cache-Control header.
 
@@ -983,7 +983,7 @@ class Response:
         Cache-Control header. The list will be joined with ", " to produce
         the value for the header.
         """,
-        format_header_value_list,
+        _format_header_value_list,
     )
     """Set the Cache-Control header.
 
@@ -992,7 +992,7 @@ class Response:
     the value for the header.
     """
 
-    content_location: Optional[str] = header_property(
+    content_location: Optional[str] = _header_property(
         'Content-Location',
         """Set the Content-Location header.
 
@@ -1009,7 +1009,7 @@ class Response:
     header should be set manually using the set_header method.
     """
 
-    content_length: Union[str, int, None] = header_property(
+    content_length: Union[str, int, None] = _header_property(
         'Content-Length',
         """Set the Content-Length header.
 
@@ -1047,7 +1047,7 @@ class Response:
 
     """
 
-    content_range: Union[str, RangeSetHeader, None] = header_property(
+    content_range: Union[str, RangeSetHeader, None] = _header_property(
         'Content-Range',
         """A tuple to use in constructing a value for the Content-Range header.
 
@@ -1065,7 +1065,7 @@ class Response:
 
         (See also: RFC 7233, Section 4.2)
         """,
-        format_range,
+        _format_range,
     )
     """A tuple to use in constructing a value for the Content-Range header.
 
@@ -1084,7 +1084,7 @@ class Response:
     (See also: RFC 7233, Section 4.2)
     """
 
-    content_type: Optional[str] = header_property(
+    content_type: Optional[str] = _header_property(
         'Content-Type',
         """Sets the Content-Type header.
 
@@ -1108,7 +1108,7 @@ class Response:
     and ``falcon.MEDIA_GIF``.
     """
 
-    downloadable_as: Optional[str] = header_property(
+    downloadable_as: Optional[str] = _header_property(
         'Content-Disposition',
         """Set the Content-Disposition header using the given filename.
 
@@ -1121,7 +1121,7 @@ class Response:
         ``filename*`` directive, whereas ``filename`` will contain the US
         ASCII fallback.
         """,
-        functools.partial(format_content_disposition, disposition_type='attachment'),
+        functools.partial(_format_content_disposition, disposition_type='attachment'),
     )
     """Set the Content-Disposition header using the given filename.
 
@@ -1135,7 +1135,7 @@ class Response:
     ASCII fallback.
     """
 
-    viewable_as: Optional[str] = header_property(
+    viewable_as: Optional[str] = _header_property(
         'Content-Disposition',
         """Set an inline Content-Disposition header using the given filename.
 
@@ -1150,7 +1150,7 @@ class Response:
 
         .. versionadded:: 3.1
         """,
-        functools.partial(format_content_disposition, disposition_type='inline'),
+        functools.partial(_format_content_disposition, disposition_type='inline'),
     )
     """Set an inline Content-Disposition header using the given filename.
 
@@ -1166,14 +1166,14 @@ class Response:
     .. versionadded:: 3.1
     """
 
-    etag: Optional[str] = header_property(
+    etag: Optional[str] = _header_property(
         'ETag',
         """Set the ETag header.
 
         The ETag header will be wrapped with double quotes ``"value"`` in case
         the user didn't pass it.
         """,
-        format_etag_header,
+        _format_etag_header,
     )
     """Set the ETag header.
 
@@ -1181,7 +1181,7 @@ class Response:
     the user didn't pass it.
     """
 
-    expires: Union[str, datetime, None] = header_property(
+    expires: Union[str, datetime, None] = _header_property(
         'Expires',
         """Set the Expires header. Set to a ``datetime`` (UTC) instance.
 
@@ -1196,7 +1196,7 @@ class Response:
         Falcon will format the ``datetime`` as an HTTP date string.
     """
 
-    last_modified: Union[str, datetime, None] = header_property(
+    last_modified: Union[str, datetime, None] = _header_property(
         'Last-Modified',
         """Set the Last-Modified header. Set to a ``datetime`` (UTC) instance.
 
@@ -1211,7 +1211,7 @@ class Response:
         Falcon will format the ``datetime`` as an HTTP date string.
     """
 
-    location: Optional[str] = header_property(
+    location: Optional[str] = _header_property(
         'Location',
         """Set the Location header.
 
@@ -1228,7 +1228,7 @@ class Response:
     header should be set manually using the set_header method.
     """
 
-    retry_after: Union[int, str, None] = header_property(
+    retry_after: Union[int, str, None] = _header_property(
         'Retry-After',
         """Set the Retry-After header.
 
@@ -1242,7 +1242,7 @@ class Response:
     value for the header. The HTTP-date syntax is not supported.
     """
 
-    vary: Union[str, Iterable[str], None] = header_property(
+    vary: Union[str, Iterable[str], None] = _header_property(
         'Vary',
         """Value to use for the Vary header.
 
@@ -1259,7 +1259,7 @@ class Response:
 
         (See also: RFC 7231, Section 7.1.4)
         """,
-        format_header_value_list,
+        _format_header_value_list,
     )
     """Value to use for the Vary header.
 
@@ -1277,7 +1277,7 @@ class Response:
     (See also: RFC 7231, Section 7.1.4)
     """
 
-    accept_ranges: Optional[str] = header_property(
+    accept_ranges: Optional[str] = _header_property(
         'Accept-Ranges',
         """Set the Accept-Ranges header.
 
@@ -1387,7 +1387,7 @@ class ResponseOptions:
     media_handlers: Handlers
     """A dict-like object for configuring the media-types to handle.
 
-    default, handlers are provided for the ``application/json``,
+    Default handlers are provided for the ``application/json``,
     ``application/x-www-form-urlencoded`` and ``multipart/form-data`` media types.
     """
     static_media_types: Dict[str, str]
@@ -1395,18 +1395,36 @@ class ResponseOptions:
 
     Defaults to ``mimetypes.types_map`` after calling ``mimetypes.init()``.
     """
+    xml_error_serialization: bool
+    """Set to ``False`` to disable automatic inclusion of the XML handler
+    in the default error serializer (:ref:`errors`) (default ``True``).
+
+    Enabling this option does not automatically render all error response in XML,
+    but only if the client prefers (via the ``Accept`` request header) XML to JSON
+    and other configured media handlers.
+
+    Note:
+        This option will default to ``False`` in Falcon 5.0 disabling XML error
+        serialization by default).
+
+    Note:
+        This option has no effect when a custom error serializer, set using
+        :meth:`~falcon.App.set_error_serializer`, is in use.
+    """
 
     __slots__ = (
         'secure_cookies_by_default',
         'default_media_type',
         'media_handlers',
         'static_media_types',
+        'xml_error_serialization',
     )
 
     def __init__(self) -> None:
         self.secure_cookies_by_default = True
         self.default_media_type = DEFAULT_MEDIA_TYPE
         self.media_handlers = Handlers()
+        self.xml_error_serialization = True
 
         if not mimetypes.inited:
             mimetypes.init()
diff --git a/falcon/response_helpers.py b/falcon/response_helpers.py
index 8e1d9020..71d397ec 100644
--- a/falcon/response_helpers.py
+++ b/falcon/response_helpers.py
@@ -18,7 +18,7 @@ from __future__ import annotations
 
 from typing import Any, Callable, Iterable, Optional, TYPE_CHECKING
 
-from falcon.typing import RangeSetHeader
+from falcon._typing import RangeSetHeader
 from falcon.util import uri
 from falcon.util.misc import secure_filename
 
@@ -26,7 +26,7 @@ if TYPE_CHECKING:
     from falcon import Response
 
 
-def header_property(
+def _header_property(
     name: str, doc: str, transform: Optional[Callable[[Any], str]] = None
 ) -> Any:
     """Create a header getter/setter.
@@ -76,7 +76,7 @@ def header_property(
     return property(fget, fset, fdel, doc)
 
 
-def format_range(value: RangeSetHeader) -> str:
+def _format_range(value: RangeSetHeader) -> str:
     """Format a range header tuple per the HTTP spec.
 
     Args:
@@ -90,7 +90,9 @@ def format_range(value: RangeSetHeader) -> str:
     return result
 
 
-def format_content_disposition(value: str, disposition_type: str = 'attachment') -> str:
+def _format_content_disposition(
+    value: str, disposition_type: str = 'attachment'
+) -> str:
     """Format a Content-Disposition header given a filename."""
 
     # NOTE(vytas): RFC 6266, Appendix D.
@@ -117,7 +119,7 @@ def format_content_disposition(value: str, disposition_type: str = 'attachment')
     )
 
 
-def format_etag_header(value: str) -> str:
+def _format_etag_header(value: str) -> str:
     """Format an ETag header, wrap it with " " in case of need."""
 
     if value[-1] != '"':
@@ -126,12 +128,12 @@ def format_etag_header(value: str) -> str:
     return value
 
 
-def format_header_value_list(iterable: Iterable[str]) -> str:
+def _format_header_value_list(iterable: Iterable[str]) -> str:
     """Join an iterable of strings with commas."""
     return ', '.join(iterable)
 
 
-def is_ascii_encodable(s: str) -> bool:
+def _is_ascii_encodable(s: str) -> bool:
     """Check if argument encodes to ascii without error."""
     try:
         s.encode('ascii')
diff --git a/falcon/routing/compiled.py b/falcon/routing/compiled.py
index 6407484c..83628878 100644
--- a/falcon/routing/compiled.py
+++ b/falcon/routing/compiled.py
@@ -35,10 +35,10 @@ from typing import (
     Union,
 )
 
+from falcon._typing import MethodDict
 from falcon.routing import converters
 from falcon.routing.util import map_http_methods
 from falcon.routing.util import set_default_responders
-from falcon.typing import MethodDict
 from falcon.util.misc import is_python_func
 from falcon.util.sync import _should_wrap_non_coroutines
 from falcon.util.sync import wrap_sync_to_async
diff --git a/falcon/routing/static.py b/falcon/routing/static.py
index cc2a5ab9..57e327ab 100644
--- a/falcon/routing/static.py
+++ b/falcon/routing/static.py
@@ -9,12 +9,12 @@ import re
 from typing import Any, ClassVar, IO, Optional, Pattern, Tuple, TYPE_CHECKING, Union
 
 import falcon
+from falcon.typing import ReadableIO
 
 if TYPE_CHECKING:
     from falcon import asgi
     from falcon import Request
     from falcon import Response
-from falcon.typing import ReadableIO
 
 
 def _open_range(
diff --git a/falcon/routing/util.py b/falcon/routing/util.py
index eb709a44..18259edd 100644
--- a/falcon/routing/util.py
+++ b/falcon/routing/util.py
@@ -22,7 +22,7 @@ from falcon import constants
 from falcon import responders
 
 if TYPE_CHECKING:
-    from falcon.typing import MethodDict
+    from falcon._typing import MethodDict
 
 
 class SuffixedMethodNotFoundError(Exception):
diff --git a/falcon/status_codes.py b/falcon/status_codes.py
index 8c49f265..67011df9 100644
--- a/falcon/status_codes.py
+++ b/falcon/status_codes.py
@@ -93,7 +93,9 @@ HTTP_411: Final[str] = '411 Length Required'
 HTTP_LENGTH_REQUIRED: Final[str] = HTTP_411
 HTTP_412: Final[str] = '412 Precondition Failed'
 HTTP_PRECONDITION_FAILED: Final[str] = HTTP_412
-HTTP_413: Final[str] = '413 Payload Too Large'
+HTTP_413: Final[str] = '413 Content Too Large'
+HTTP_CONTENT_TOO_LARGE: Final[str] = HTTP_413
+HTTP_PAYLOAD_TOO_LARGE: Final[str] = HTTP_413
 HTTP_REQUEST_ENTITY_TOO_LARGE: Final[str] = HTTP_413
 HTTP_414: Final[str] = '414 URI Too Long'
 HTTP_REQUEST_URI_TOO_LONG: Final[str] = HTTP_414
@@ -330,6 +332,7 @@ __all__ = (
     'HTTP_BAD_GATEWAY',
     'HTTP_BAD_REQUEST',
     'HTTP_CONFLICT',
+    'HTTP_CONTENT_TOO_LARGE',
     'HTTP_CONTINUE',
     'HTTP_CREATED',
     'HTTP_EARLY_HINTS',
@@ -369,7 +372,6 @@ __all__ = (
     'HTTP_PROCESSING',
     'HTTP_PROXY_AUTHENTICATION_REQUIRED',
     'HTTP_REQUESTED_RANGE_NOT_SATISFIABLE',
-    'HTTP_REQUEST_ENTITY_TOO_LARGE',
     'HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE',
     'HTTP_REQUEST_TIMEOUT',
     'HTTP_REQUEST_URI_TOO_LONG',
diff --git a/falcon/stream.py b/falcon/stream.py
index f5bbe463..127c44ff 100644
--- a/falcon/stream.py
+++ b/falcon/stream.py
@@ -19,6 +19,8 @@ from __future__ import annotations
 import io
 from typing import BinaryIO, Callable, List, Optional, TypeVar, Union
 
+from falcon.util import deprecated
+
 __all__ = ('BoundedStream',)
 
 
@@ -43,12 +45,6 @@ class BoundedStream(io.IOBase):
         stream: Instance of ``socket._fileobject`` from
             ``environ['wsgi.input']``
         stream_len: Expected content length of the stream.
-
-    Attributes:
-        eof (bool): ``True`` if there is no more data to read from
-            the stream, otherwise ``False``.
-        is_exhausted (bool): Deprecated alias for `eof`.
-
     """
 
     def __init__(self, stream: BinaryIO, stream_len: int) -> None:
@@ -166,9 +162,21 @@ class BoundedStream(io.IOBase):
 
     @property
     def eof(self) -> bool:
+        """``True`` if there is no more data to read from the stream,
+        otherwise ``False``.
+        """  # noqa: D205
         return self._bytes_remaining <= 0
 
-    is_exhausted = eof
+    @property
+    # NOTE(caselit): Deprecated long ago. Warns since 4.0.
+    @deprecated(
+        'Use `eof` instead. '
+        '(This compatibility alias will be removed in Falcon 5.0.)',
+        is_property=True,
+    )
+    def is_exhausted(self) -> bool:
+        """Deprecated alias for `eof`."""
+        return self.eof
 
 
 # NOTE(kgriffs): Alias for backwards-compat
diff --git a/falcon/testing/client.py b/falcon/testing/client.py
index 1521128e..e1f14959 100644
--- a/falcon/testing/client.py
+++ b/falcon/testing/client.py
@@ -18,21 +18,48 @@ This package includes utilities for simulating HTTP requests against a
 WSGI callable, without having to stand up a WSGI server.
 """
 
+from __future__ import annotations
+
 import asyncio
 import datetime as dt
+from http.cookies import Morsel
 import inspect
 import json as json_module
 import time
-from typing import Dict, Optional, Sequence, Union
+from typing import (
+    Any,
+    Awaitable,
+    Callable,
+    cast,
+    Coroutine,
+    Dict,
+    Iterable,
+    Literal,
+    Mapping,
+    Optional,
+    overload,
+    Sequence,
+    TextIO,
+    Tuple,
+    TYPE_CHECKING,
+    TypeVar,
+    Union,
+)
 import warnings
 import wsgiref.validate
 
+from falcon._typing import CookieArg
+from falcon._typing import HeaderArg
+from falcon._typing import HeaderIter
+from falcon._typing import HeaderMapping
+from falcon.asgi_spec import AsgiEvent
 from falcon.asgi_spec import ScopeType
 from falcon.constants import COMBINED_METHODS
 from falcon.constants import MEDIA_JSON
 from falcon.errors import CompatibilityError
 from falcon.testing import helpers
 from falcon.testing.srmock import StartResponseMock
+from falcon.typing import Headers
 from falcon.util import async_to_sync
 from falcon.util import CaseInsensitiveDict
 from falcon.util import code_to_http_status
@@ -40,6 +67,10 @@ from falcon.util import http_cookies
 from falcon.util import http_date_to_dt
 from falcon.util import to_query_str
 
+if TYPE_CHECKING:
+    import falcon
+    from falcon import asgi
+
 warnings.filterwarnings(
     'error',
     ('Unknown REQUEST_METHOD: ' + "'({})'".format('|'.join(COMBINED_METHODS))),
@@ -48,18 +79,21 @@ warnings.filterwarnings(
     0,
 )
 
+_T = TypeVar('_T', bound=Callable[..., Any])
 
-def _simulate_method_alias(method, version_added='3.1', replace_name=None):
-    return_type = inspect.signature(method).return_annotation
 
-    def alias(client, *args, **kwargs) -> return_type:
+def _simulate_method_alias(
+    method: _T, version_added: str = '3.1', replace_name: Optional[str] = None
+) -> _T:
+    def alias(client: Any, *args: Any, **kwargs: Any) -> Any:
         return method(client, *args, **kwargs)
 
-    async def async_alias(client, *args, **kwargs) -> return_type:
+    async def async_alias(client: Any, *args: Any, **kwargs: Any) -> Any:
         return await method(client, *args, **kwargs)
 
     alias = async_alias if inspect.iscoroutinefunction(method) else alias
 
+    assert method.__doc__
     alias.__doc__ = method.__doc__ + '\n        .. versionadded:: {}\n'.format(
         version_added
     )
@@ -69,39 +103,26 @@ def _simulate_method_alias(method, version_added='3.1', replace_name=None):
     else:
         alias.__name__ = method.__name__.partition('simulate_')[-1]
 
-    return alias
+    return cast(_T, alias)
 
 
 class Cookie:
     """Represents a cookie returned by a simulated request.
 
     Args:
-        morsel: A ``Morsel`` object from which to derive the cookie
-            data.
-
-    Attributes:
-        name (str): The cookie's name.
-        value (str): The value of the cookie.
-        expires(datetime.datetime): Expiration timestamp for the cookie,
-            or ``None`` if not specified.
-        path (str): The path prefix to which this cookie is restricted,
-            or ``None`` if not specified.
-        domain (str): The domain to which this cookie is restricted,
-            or ``None`` if not specified.
-        max_age (int): The lifetime of the cookie in seconds, or
-            ``None`` if not specified.
-        secure (bool): Whether or not the cookie may only only be
-            transmitted from the client via HTTPS.
-        http_only (bool): Whether or not the cookie may only be
-            included in unscripted requests from the client.
-        same_site (str): Specifies whether cookies are send in
-            cross-site requests. Possible values are 'Lax', 'Strict'
-            and 'None'. ``None`` if not specified.
-        partitioned (bool): Indicates if the cookie has the
-            ``Partitioned`` flag set.
+        morsel: A ``Morsel`` object from which to derive the cookie data.
     """
 
-    def __init__(self, morsel):
+    _expires: Optional[str]
+    _path: str
+    _domain: str
+    _max_age: Optional[str]
+    _secure: Optional[str]
+    _httponly: Optional[str]
+    _samesite: Optional[str]
+    _partitioned: Optional[str]
+
+    def __init__(self, morsel: Morsel) -> None:
         self._name = morsel.key
         self._value = morsel.value
 
@@ -120,46 +141,67 @@ class Cookie:
 
     @property
     def name(self) -> str:
+        """The cookie's name."""
         return self._name
 
     @property
     def value(self) -> str:
+        """The value of the cookie."""
         return self._value
 
     @property
     def expires(self) -> Optional[dt.datetime]:
-        if self._expires:  # type: ignore[attr-defined]
-            return http_date_to_dt(self._expires, obs_date=True)  # type: ignore[attr-defined]  # noqa E501
+        """Expiration timestamp for the cookie, or ``None`` if not specified."""
+        if self._expires:
+            return http_date_to_dt(self._expires, obs_date=True)
 
         return None
 
     @property
     def path(self) -> str:
-        return self._path  # type: ignore[attr-defined]
+        """The path prefix to which this cookie is restricted.
+
+        An empty string if not specified.
+        """
+        return self._path
 
     @property
     def domain(self) -> str:
-        return self._domain  # type: ignore[attr-defined]
+        """The domain to which this cookie is restricted.
+
+        An empty string if not specified.
+        """
+        return self._domain
 
     @property
     def max_age(self) -> Optional[int]:
-        return int(self._max_age) if self._max_age else None  # type: ignore[attr-defined]  # noqa E501
+        """The lifetime of the cookie in seconds, or ``None`` if not specified."""
+        return int(self._max_age) if self._max_age else None
 
     @property
     def secure(self) -> bool:
-        return bool(self._secure)  # type: ignore[attr-defined]
+        """Whether or not the cookie may only only be transmitted
+        from the client via HTTPS.
+        """  # noqa: D205
+        return bool(self._secure)
 
     @property
     def http_only(self) -> bool:
-        return bool(self._httponly)  # type: ignore[attr-defined]
+        """Whether or not the cookie will be visible from JavaScript in the client."""
+        return bool(self._httponly)
 
     @property
     def same_site(self) -> Optional[str]:
-        return self._samesite if self._samesite else None  # type: ignore[attr-defined]
+        """Specifies whether cookies are send in cross-site requests.
+
+        Possible values are 'Lax', 'Strict' and 'None'. ``None`` if not specified.
+        """
+        return self._samesite if self._samesite else None
 
     @property
     def partitioned(self) -> bool:
-        return bool(self._partitioned)  # type: ignore[attr-defined]
+        """Indicates if the cookie has the ``Partitioned`` flag set."""
+        return bool(self._partitioned)
 
 
 class _ResultBase:
@@ -170,36 +212,9 @@ class _ResultBase:
             reason string
         headers (list): A list of (header_name, header_value) tuples,
             per PEP-3333
-
-    Attributes:
-        status (str): HTTP status string given in the response
-        status_code (int): The code portion of the HTTP status string
-        headers (CaseInsensitiveDict): A case-insensitive dictionary
-            containing all the headers in the response, except for
-            cookies, which may be accessed via the `cookies`
-            attribute.
-
-            Note:
-
-                Multiple instances of a header in the response are
-                currently not supported; it is unspecified which value
-                will "win" and be represented in `headers`.
-
-        cookies (dict): A dictionary of
-            :class:`falcon.testing.Cookie` values parsed from the
-            response, by name.
-
-            The cookies dictionary can be used directly in subsequent requests::
-
-                client = testing.TestClient(app)
-                response_one = client.simulate_get('/')
-                response_two = client.simulate_post('/', cookies=response_one.cookies)
-
-        encoding (str): Text encoding of the response body, or ``None``
-            if the encoding can not be determined.
     """
 
-    def __init__(self, status, headers):
+    def __init__(self, status: str, headers: HeaderIter) -> None:
         self._status = status
         self._status_code = int(status[:3])
         self._headers = CaseInsensitiveDict(headers)
@@ -217,31 +232,53 @@ class _ResultBase:
 
     @property
     def status(self) -> str:
+        """HTTP status string given in the response."""
         return self._status
 
     @property
     def status_code(self) -> int:
+        """The code portion of the HTTP status string."""
         return self._status_code
 
     @property
-    def headers(self) -> CaseInsensitiveDict:
-        # NOTE(kgriffs): It would probably be better to annotate this with
-        #   a generic Mapping[str, str] type, but currently there is an
-        #   incompatibility with Cython that prevents us from modifying
-        #   CaseInsensitiveDict to inherit from a generic MutableMapping
-        #   type. This might be resolved in the future by moving
-        #   the CaseInsensitiveDict implementation to the falcon.testing
-        #   module so that it is no longer cythonized.
-        return self._headers
+    def headers(self) -> Headers:
+        """A case-insensitive dictionary containing all the headers in the response,
+        except for cookies, which may be accessed via the `cookies` attribute.
+
+        Note:
+
+            Multiple instances of a header in the response are
+            currently not supported; it is unspecified which value
+            will "win" and be represented in `headers`.
+        """  # noqa: D205
+        return self._headers  # type: ignore[return-value]
 
     @property
     def cookies(self) -> Dict[str, Cookie]:
+        """A dictionary of :class:`falcon.testing.Cookie` values parsed from
+        the response, by name.
+
+        The cookies dictionary can be used directly in subsequent requests::
+
+            client = testing.TestClient(app)
+            response_one = client.simulate_get('/')
+            response_two = client.simulate_post('/', cookies=response_one.cookies)
+        """  # noqa: D205
         return self._cookies
 
     @property
-    def encoding(self) -> str:
+    def encoding(self) -> Optional[str]:
+        """Text encoding of the response body.
+
+        Returns ``None`` if the encoding can not be determined.
+        """
         return self._encoding
 
+    @property
+    def content_type(self) -> Optional[str]:
+        """Return the ``Content-Type`` header or ``None`` if missing."""
+        return self.headers.get('Content-Type')
+
 
 class ResultBodyStream:
     """Simple forward-only reader for a streamed test result body.
@@ -252,7 +289,7 @@ class ResultBodyStream:
             collected.
     """
 
-    def __init__(self, chunks: Sequence[bytes]):
+    def __init__(self, chunks: Sequence[bytes]) -> None:
         self._chunks = chunks
         self._chunk_pos = 0
 
@@ -290,48 +327,27 @@ class Result(_ResultBase):
             reason string
         headers (list): A list of (header_name, header_value) tuples,
             per PEP-3333
-
-    Attributes:
-        status (str): HTTP status string given in the response
-        status_code (int): The code portion of the HTTP status string
-        headers (CaseInsensitiveDict): A case-insensitive dictionary
-            containing all the headers in the response, except for
-            cookies, which may be accessed via the `cookies`
-            attribute.
-
-            Note:
-
-                Multiple instances of a header in the response are
-                currently not supported; it is unspecified which value
-                will "win" and be represented in `headers`.
-
-        cookies (dict): A dictionary of
-            :class:`falcon.testing.Cookie` values parsed from the
-            response, by name.
-        encoding (str): Text encoding of the response body, or ``None``
-            if the encoding can not be determined.
-        content (bytes): Raw response body, or ``bytes`` if the
-            response body was empty.
-        text (str): Decoded response body of type ``str``.
-            If the content type does not specify an encoding, UTF-8 is
-            assumed.
-        json (JSON serializable): Deserialized JSON body. Will be ``None`` if
-            the body has no content to deserialize. Otherwise, raises an error
-            if the response is not valid JSON.
     """
 
-    def __init__(self, iterable, status, headers):
+    def __init__(
+        self, iterable: Iterable[bytes], status: str, headers: HeaderIter
+    ) -> None:
         super().__init__(status, headers)
 
-        self._text = None
+        self._text: Optional[str] = None
         self._content = b''.join(iterable)
 
     @property
     def content(self) -> bytes:
+        """Raw response body, or an ``b''`` if the response body was empty."""
         return self._content
 
     @property
     def text(self) -> str:
+        """Decoded response body of type ``str``.
+
+        If the content type does not specify an encoding, UTF-8 is assumed.
+        """
         if self._text is None:
             if not self.content:
                 self._text = ''
@@ -346,14 +362,19 @@ class Result(_ResultBase):
         return self._text
 
     @property
-    def json(self) -> Optional[Union[dict, list, str, int, float, bool]]:
+    def json(self) -> Any:
+        """Deserialized JSON body.
+
+        Will be ``None`` if the body has no content to deserialize.
+        Otherwise, raises an error if the response is not valid JSON.
+        """
         if not self.text:
             return None
 
         return json_module.loads(self.text)
 
-    def __repr__(self):
-        content_type = self.headers.get('Content-Type', '')
+    def __repr__(self) -> str:
+        content_type = self.content_type or ''
 
         if len(self.content) > 40:
             content = self.content[:20] + b'...' + self.content[-20:]
@@ -384,30 +405,16 @@ class StreamedResult(_ResultBase):
             application via its receive() method.
             :meth:`~.finalize` will cause the event emitter to
             simulate an ``'http.disconnect'`` event before returning.
-
-    Attributes:
-        status (str): HTTP status string given in the response
-        status_code (int): The code portion of the HTTP status string
-        headers (CaseInsensitiveDict): A case-insensitive dictionary
-            containing all the headers in the response, except for
-            cookies, which may be accessed via the `cookies`
-            attribute.
-
-            Note:
-
-                Multiple instances of a header in the response are
-                currently not supported; it is unspecified which value
-                will "win" and be represented in `headers`.
-
-        cookies (dict): A dictionary of
-            :class:`falcon.testing.Cookie` values parsed from the
-            response, by name.
-        encoding (str): Text encoding of the response body, or ``None``
-            if the encoding can not be determined.
-        stream (ResultStream): Raw response body, as a byte stream.
     """
 
-    def __init__(self, body_chunks, status, headers, task, req_event_emitter):
+    def __init__(
+        self,
+        body_chunks: Sequence[bytes],
+        status: str,
+        headers: HeaderIter,
+        task: asyncio.Task,
+        req_event_emitter: helpers.ASGIRequestEventEmitter,
+    ):
         super().__init__(status, headers)
 
         self._task = task
@@ -416,9 +423,10 @@ class StreamedResult(_ResultBase):
 
     @property
     def stream(self) -> ResultBodyStream:
+        """Raw response body, as a byte stream."""
         return self._stream
 
-    async def finalize(self):
+    async def finalize(self) -> None:
         """Finalize the encapsulated simulated request.
 
         This method causes the request event emitter to begin emitting
@@ -434,29 +442,29 @@ class StreamedResult(_ResultBase):
 #   appears to be "hanging", which might indicates that the app is
 #   not handling the reception of events correctly.
 def simulate_request(
-    app,
-    method='GET',
-    path='/',
-    query_string=None,
-    headers=None,
-    content_type=None,
-    body=None,
-    json=None,
-    file_wrapper=None,
-    wsgierrors=None,
-    params=None,
-    params_csv=False,
-    protocol='http',
-    host=helpers.DEFAULT_HOST,
-    remote_addr=None,
-    extras=None,
-    http_version='1.1',
-    port=None,
-    root_path=None,
-    cookies=None,
-    asgi_chunk_size=4096,
-    asgi_disconnect_ttl=300,
-) -> _ResultBase:
+    app: Callable[..., Any],  # accept any asgi/wsgi app
+    method: str = 'GET',
+    path: str = '/',
+    query_string: Optional[str] = None,
+    headers: Optional[HeaderArg] = None,
+    content_type: Optional[str] = None,
+    body: Optional[Union[str, bytes]] = None,
+    json: Optional[Any] = None,
+    file_wrapper: Optional[Callable[..., Any]] = None,
+    wsgierrors: Optional[TextIO] = None,
+    params: Optional[Mapping[str, Any]] = None,
+    params_csv: bool = False,
+    protocol: str = 'http',
+    host: str = helpers.DEFAULT_HOST,
+    remote_addr: Optional[str] = None,
+    extras: Optional[Mapping[str, Any]] = None,
+    http_version: str = '1.1',
+    port: Optional[int] = None,
+    root_path: Optional[str] = None,
+    cookies: Optional[CookieArg] = None,
+    asgi_chunk_size: int = 4096,
+    asgi_disconnect_ttl: int = 300,
+) -> Result:
     """Simulate a request to a WSGI or ASGI application.
 
     Performs a request against a WSGI or ASGI application. In the case of
@@ -569,7 +577,7 @@ def simulate_request(
     """
 
     if _is_asgi_app(app):
-        return async_to_sync(
+        return async_to_sync(  # type: ignore[return-value]
             _simulate_request_asgi,
             app,
             method=method,
@@ -611,7 +619,7 @@ def simulate_request(
         path=path,
         query_string=(query_string or ''),
         headers=headers,
-        body=body,
+        body=body or b'',
         file_wrapper=file_wrapper,
         host=host,
         remote_addr=remote_addr,
@@ -639,7 +647,63 @@ def simulate_request(
 
     iterable = validator(env, srmock)
 
-    return Result(helpers.closed_wsgi_iterable(iterable), srmock.status, srmock.headers)
+    data = helpers.closed_wsgi_iterable(iterable)
+    assert srmock.status is not None and srmock.headers is not None
+    return Result(data, srmock.status, srmock.headers)
+
+
+@overload
+async def _simulate_request_asgi(
+    app: Callable[..., Coroutine[Any, Any, Any]],
+    method: str = ...,
+    path: str = ...,
+    query_string: Optional[str] = ...,
+    headers: Optional[HeaderArg] = ...,
+    content_type: Optional[str] = ...,
+    body: Optional[Union[str, bytes]] = ...,
+    json: Optional[Any] = ...,
+    params: Optional[Mapping[str, Any]] = ...,
+    params_csv: bool = ...,
+    protocol: str = ...,
+    host: str = ...,
+    remote_addr: Optional[str] = ...,
+    extras: Optional[Mapping[str, Any]] = ...,
+    http_version: str = ...,
+    port: Optional[int] = ...,
+    root_path: Optional[str] = ...,
+    asgi_chunk_size: int = ...,
+    asgi_disconnect_ttl: int = ...,
+    cookies: Optional[CookieArg] = ...,
+    _one_shot: Literal[False] = ...,
+    _stream_result: Literal[True] = ...,
+) -> StreamedResult: ...
+
+
+@overload
+async def _simulate_request_asgi(
+    app: Callable[..., Coroutine[Any, Any, Any]],
+    method: str = ...,
+    path: str = ...,
+    query_string: Optional[str] = ...,
+    headers: Optional[HeaderArg] = ...,
+    content_type: Optional[str] = ...,
+    body: Optional[Union[str, bytes]] = ...,
+    json: Optional[Any] = ...,
+    params: Optional[Mapping[str, Any]] = ...,
+    params_csv: bool = ...,
+    protocol: str = ...,
+    host: str = ...,
+    remote_addr: Optional[str] = ...,
+    extras: Optional[Mapping[str, Any]] = ...,
+    http_version: str = ...,
+    port: Optional[int] = ...,
+    root_path: Optional[str] = ...,
+    asgi_chunk_size: int = ...,
+    asgi_disconnect_ttl: int = ...,
+    cookies: Optional[CookieArg] = ...,
+    _one_shot: Literal[True] = ...,
+    _stream_result: bool = ...,
+) -> Result: ...
 
 
 # NOTE(kgriffs): The default of asgi_disconnect_ttl was chosen to be
@@ -647,34 +711,34 @@ def simulate_request(
 #   appears to be "hanging", which might indicates that the app is
 #   not handling the reception of events correctly.
 async def _simulate_request_asgi(
-    app,
-    method='GET',
-    path='/',
-    query_string=None,
-    headers=None,
-    content_type=None,
-    body=None,
-    json=None,
-    params=None,
-    params_csv=True,
-    protocol='http',
-    host=helpers.DEFAULT_HOST,
-    remote_addr=None,
-    extras=None,
-    http_version='1.1',
-    port=None,
-    root_path=None,
-    asgi_chunk_size=4096,
-    asgi_disconnect_ttl=300,
-    cookies=None,
+    app: Callable[..., Coroutine[Any, Any, Any]],  # accept any asgi app
+    method: str = 'GET',
+    path: str = '/',
+    query_string: Optional[str] = None,
+    headers: Optional[HeaderArg] = None,
+    content_type: Optional[str] = None,
+    body: Optional[Union[str, bytes]] = None,
+    json: Optional[Any] = None,
+    params: Optional[Mapping[str, Any]] = None,
+    params_csv: bool = False,
+    protocol: str = 'http',
+    host: str = helpers.DEFAULT_HOST,
+    remote_addr: Optional[str] = None,
+    extras: Optional[Mapping[str, Any]] = None,
+    http_version: str = '1.1',
+    port: Optional[int] = None,
+    root_path: Optional[str] = None,
+    asgi_chunk_size: int = 4096,
+    asgi_disconnect_ttl: int = 300,
+    cookies: Optional[CookieArg] = None,
     # NOTE(kgriffs): These are undocumented because they are only
     #   meant to be used internally by the framework (i.e., they are
     #   not part of the public interface.) In case we ever expose
     #   simulate_request_asgi() as part of the public interface, we
     #   don't want these kwargs to be documented.
-    _one_shot=True,
-    _stream_result=False,
-) -> _ResultBase:
+    _one_shot: bool = True,
+    _stream_result: bool = False,
+) -> Union[Result, StreamedResult]:
     """Simulate a request to an ASGI application.
 
     Keyword Args:
@@ -816,7 +880,7 @@ async def _simulate_request_asgi(
     # ---------------------------------------------------------------------
 
     if asgi_disconnect_ttl == 0:  # Special case
-        disconnect_at = 0
+        disconnect_at = 0.0
     else:
         disconnect_at = time.time() + max(0, asgi_disconnect_ttl)
 
@@ -872,7 +936,7 @@ async def _simulate_request_asgi(
     lifespan_event_collector = helpers.ASGIResponseEventCollector()
     # ---------------------------------------------------------------------
 
-    async def conductor():
+    async def conductor() -> None:
         # NOTE(kgriffs): We assume this is a Falcon ASGI app, which supports
         #   the lifespan protocol and thus we do not need to catch
         #   exceptions that would signify no lifespan protocol support.
@@ -990,24 +1054,29 @@ class ASGIConductor:
         headers (dict): Default headers to set on every request (default
             ``None``). These defaults may be overridden by passing values
             for the same headers to one of the ``simulate_*()`` methods.
-
-    Attributes:
-        app: The app that this client instance was configured to use.
-
     """
 
-    def __init__(self, app, headers=None):
+    # NOTE(caseit): while any asgi app is accept, type this as a falcon
+    # asgi app for user convenience
+    app: asgi.App
+    """The app that this client instance was configured to use."""
+
+    def __init__(
+        self,
+        app: Callable[..., Any],  # accept any asgi app
+        headers: Optional[HeaderMapping] = None,
+    ):
         if not _is_asgi_app(app):
             raise CompatibilityError('ASGIConductor may only be used with an ASGI app')
 
-        self.app = app
+        self.app = app  # type: ignore[assignment]
         self._default_headers = headers
 
         self._shutting_down = asyncio.Condition()
         self._lifespan_event_collector = helpers.ASGIResponseEventCollector()
-        self._lifespan_task = None
+        self._lifespan_task: Optional[asyncio.Task] = None
 
-    async def __aenter__(self):
+    async def __aenter__(self) -> ASGIConductor:
         lifespan_scope = {
             'type': ScopeType.LIFESPAN,
             'asgi': {
@@ -1031,7 +1100,7 @@ class ASGIConductor:
 
         return self
 
-    async def __aexit__(self, ex_type, ex, tb):
+    async def __aexit__(self, ex_type: Any, ex: Any, tb: Any) -> bool:
         if ex_type:
             return False
 
@@ -1041,18 +1110,21 @@ class ASGIConductor:
             self._shutting_down.notify()
 
         await _wait_for_shutdown(self._lifespan_event_collector.events)
+        assert self._lifespan_task is not None
         await self._lifespan_task
 
         return True
 
-    async def simulate_get(self, path='/', **kwargs) -> _ResultBase:
+    async def simulate_get(self, path: str = '/', **kwargs: Any) -> Result:
         """Simulate a GET request to an ASGI application.
 
         (See also: :meth:`falcon.testing.simulate_get`)
         """
         return await self.simulate_request('GET', path, **kwargs)
 
-    def simulate_get_stream(self, path='/', **kwargs):
+    def simulate_get_stream(
+        self, path: str = '/', **kwargs: Any
+    ) -> _AsyncContextManager:
         """Simulate a GET request to an ASGI application with a streamed response.
 
         (See also: :meth:`falcon.testing.simulate_get` for a list of
@@ -1086,7 +1158,7 @@ class ASGIConductor:
 
         return _AsyncContextManager(self.simulate_request('GET', path, **kwargs))
 
-    def simulate_ws(self, path='/', **kwargs):
+    def simulate_ws(self, path: str = '/', **kwargs: Any) -> _WSContextManager:
         """Simulate a WebSocket connection to an ASGI application.
 
         All keyword arguments are passed through to
@@ -1114,53 +1186,63 @@ class ASGIConductor:
 
         return _WSContextManager(ws, task_req)
 
-    async def simulate_head(self, path='/', **kwargs) -> _ResultBase:
+    async def simulate_head(self, path: str = '/', **kwargs: Any) -> Result:
         """Simulate a HEAD request to an ASGI application.
 
         (See also: :meth:`falcon.testing.simulate_head`)
         """
         return await self.simulate_request('HEAD', path, **kwargs)
 
-    async def simulate_post(self, path='/', **kwargs) -> _ResultBase:
+    async def simulate_post(self, path: str = '/', **kwargs: Any) -> Result:
         """Simulate a POST request to an ASGI application.
 
         (See also: :meth:`falcon.testing.simulate_post`)
         """
         return await self.simulate_request('POST', path, **kwargs)
 
-    async def simulate_put(self, path='/', **kwargs) -> _ResultBase:
+    async def simulate_put(self, path: str = '/', **kwargs: Any) -> Result:
         """Simulate a PUT request to an ASGI application.
 
         (See also: :meth:`falcon.testing.simulate_put`)
         """
         return await self.simulate_request('PUT', path, **kwargs)
 
-    async def simulate_options(self, path='/', **kwargs) -> _ResultBase:
+    async def simulate_options(self, path: str = '/', **kwargs: Any) -> Result:
         """Simulate an OPTIONS request to an ASGI application.
 
         (See also: :meth:`falcon.testing.simulate_options`)
         """
         return await self.simulate_request('OPTIONS', path, **kwargs)
 
-    async def simulate_patch(self, path='/', **kwargs) -> _ResultBase:
+    async def simulate_patch(self, path: str = '/', **kwargs: Any) -> Result:
         """Simulate a PATCH request to an ASGI application.
 
         (See also: :meth:`falcon.testing.simulate_patch`)
         """
         return await self.simulate_request('PATCH', path, **kwargs)
 
-    async def simulate_delete(self, path='/', **kwargs) -> _ResultBase:
+    async def simulate_delete(self, path: str = '/', **kwargs: Any) -> Result:
         """Simulate a DELETE request to an ASGI application.
 
         (See also: :meth:`falcon.testing.simulate_delete`)
         """
         return await self.simulate_request('DELETE', path, **kwargs)
 
-    async def simulate_request(self, *args, **kwargs) -> _ResultBase:
+    @overload
+    async def simulate_request(
+        self, *args: Any, _stream_result: Literal[True], **kwargs: Any
+    ) -> StreamedResult: ...
+
+    @overload
+    async def simulate_request(self, *args: Any, **kwargs: Any) -> Result: ...
+
+    async def simulate_request(
+        self, *args: Any, **kwargs: Any
+    ) -> Union[Result, StreamedResult]:
         """Simulate a request to an ASGI application.
 
-        Wraps :meth:`falcon.testing.simulate_request` to perform a
-        WSGI request directly against ``self.app``. Equivalent to::
+        Wraps :meth:`falcon.testing.simulate_request` to perform an
+        ASGI request directly against ``self.app``. Equivalent to::
 
             falcon.testing.simulate_request(self.app, *args, **kwargs)
         """
@@ -1168,9 +1250,9 @@ class ASGIConductor:
         if self._default_headers:
             # NOTE(kgriffs): Handle the case in which headers is explicitly
             # set to None.
-            additional_headers = kwargs.get('headers', {}) or {}
+            additional_headers = kwargs.get('headers') or {}
 
-            merged_headers = self._default_headers.copy()
+            merged_headers = dict(self._default_headers)
             merged_headers.update(additional_headers)
 
             kwargs['headers'] = merged_headers
@@ -1192,7 +1274,7 @@ class ASGIConductor:
     websocket = _simulate_method_alias(simulate_ws, replace_name='websocket')
 
 
-def simulate_get(app, path, **kwargs) -> _ResultBase:
+def simulate_get(app: Callable[..., Any], path: str, **kwargs: Any) -> Result:
     """Simulate a GET request to a WSGI or ASGI application.
 
     Equivalent to::
@@ -1295,7 +1377,7 @@ def simulate_get(app, path, **kwargs) -> _ResultBase:
     return simulate_request(app, 'GET', path, **kwargs)
 
 
-def simulate_head(app, path, **kwargs) -> _ResultBase:
+def simulate_head(app: Callable[..., Any], path: str, **kwargs: Any) -> Result:
     """Simulate a HEAD request to a WSGI or ASGI application.
 
     Equivalent to::
@@ -1392,7 +1474,7 @@ def simulate_head(app, path, **kwargs) -> _ResultBase:
     return simulate_request(app, 'HEAD', path, **kwargs)
 
 
-def simulate_post(app, path, **kwargs) -> _ResultBase:
+def simulate_post(app: Callable[..., Any], path: str, **kwargs: Any) -> Result:
     """Simulate a POST request to a WSGI or ASGI application.
 
     Equivalent to::
@@ -1503,7 +1585,7 @@ def simulate_post(app, path, **kwargs) -> _ResultBase:
     return simulate_request(app, 'POST', path, **kwargs)
 
 
-def simulate_put(app, path, **kwargs) -> _ResultBase:
+def simulate_put(app: Callable[..., Any], path: str, **kwargs: Any) -> Result:
     """Simulate a PUT request to a WSGI or ASGI application.
 
     Equivalent to::
@@ -1614,7 +1696,7 @@ def simulate_put(app, path, **kwargs) -> _ResultBase:
     return simulate_request(app, 'PUT', path, **kwargs)
 
 
-def simulate_options(app, path, **kwargs) -> _ResultBase:
+def simulate_options(app: Callable[..., Any], path: str, **kwargs: Any) -> Result:
     """Simulate an OPTIONS request to a WSGI or ASGI application.
 
     Equivalent to::
@@ -1703,7 +1785,7 @@ def simulate_options(app, path, **kwargs) -> _ResultBase:
     return simulate_request(app, 'OPTIONS', path, **kwargs)
 
 
-def simulate_patch(app, path, **kwargs) -> _ResultBase:
+def simulate_patch(app: Callable[..., Any], path: str, **kwargs: Any) -> Result:
     """Simulate a PATCH request to a WSGI or ASGI application.
 
     Equivalent to::
@@ -1809,7 +1891,7 @@ def simulate_patch(app, path, **kwargs) -> _ResultBase:
     return simulate_request(app, 'PATCH', path, **kwargs)
 
 
-def simulate_delete(app, path, **kwargs) -> _ResultBase:
+def simulate_delete(app: Callable[..., Any], path: str, **kwargs: Any) -> Result:
     """Simulate a DELETE request to a WSGI or ASGI application.
 
     Equivalent to::
@@ -1971,21 +2053,26 @@ class TestClient:
         headers (dict): Default headers to set on every request (default
             ``None``). These defaults may be overridden by passing values
             for the same headers to one of the ``simulate_*()`` methods.
-
-    Attributes:
-        app: The app that this client instance was configured to use.
-
     """
 
     # NOTE(aryaniyaps): Prevent pytest from collecting tests on the class.
     __test__ = False
 
-    def __init__(self, app, headers=None):
-        self.app = app
+    # NOTE(caseit): while any asgi/wsgi app is accept, type this as a falcon
+    # app for user convenience
+    app: falcon.App
+    """The app that this client instance was configured to use."""
+
+    def __init__(
+        self,
+        app: Callable[..., Any],  # accept any asgi/wsgi app
+        headers: Optional[HeaderMapping] = None,
+    ) -> None:
+        self.app = app  # type: ignore[assignment]
         self._default_headers = headers
-        self._conductor = None
+        self._conductor: Optional[ASGIConductor] = None
 
-    async def __aenter__(self):
+    async def __aenter__(self) -> ASGIConductor:
         if not _is_asgi_app(self.app):
             raise CompatibilityError(
                 'a conductor context manager may only be used with a Falcon ASGI app'
@@ -2000,7 +2087,8 @@ class TestClient:
 
         return self._conductor
 
-    async def __aexit__(self, ex_type, ex, tb):
+    async def __aexit__(self, ex_type: Any, ex: Any, tb: Any) -> bool:
+        assert self._conductor is not None
         result = await self._conductor.__aexit__(ex_type, ex, tb)
 
         # NOTE(kgriffs): Reset to allow this instance of TestClient to be
@@ -2009,56 +2097,56 @@ class TestClient:
 
         return result
 
-    def simulate_get(self, path='/', **kwargs) -> _ResultBase:
+    def simulate_get(self, path: str = '/', **kwargs: Any) -> Result:
         """Simulate a GET request to a WSGI application.
 
         (See also: :meth:`falcon.testing.simulate_get`)
         """
         return self.simulate_request('GET', path, **kwargs)
 
-    def simulate_head(self, path='/', **kwargs) -> _ResultBase:
+    def simulate_head(self, path: str = '/', **kwargs: Any) -> Result:
         """Simulate a HEAD request to a WSGI application.
 
         (See also: :meth:`falcon.testing.simulate_head`)
         """
         return self.simulate_request('HEAD', path, **kwargs)
 
-    def simulate_post(self, path='/', **kwargs) -> _ResultBase:
+    def simulate_post(self, path: str = '/', **kwargs: Any) -> Result:
         """Simulate a POST request to a WSGI application.
 
         (See also: :meth:`falcon.testing.simulate_post`)
         """
         return self.simulate_request('POST', path, **kwargs)
 
-    def simulate_put(self, path='/', **kwargs) -> _ResultBase:
+    def simulate_put(self, path: str = '/', **kwargs: Any) -> Result:
         """Simulate a PUT request to a WSGI application.
 
         (See also: :meth:`falcon.testing.simulate_put`)
         """
         return self.simulate_request('PUT', path, **kwargs)
 
-    def simulate_options(self, path='/', **kwargs) -> _ResultBase:
+    def simulate_options(self, path: str = '/', **kwargs: Any) -> Result:
         """Simulate an OPTIONS request to a WSGI application.
 
         (See also: :meth:`falcon.testing.simulate_options`)
         """
         return self.simulate_request('OPTIONS', path, **kwargs)
 
-    def simulate_patch(self, path='/', **kwargs) -> _ResultBase:
+    def simulate_patch(self, path: str = '/', **kwargs: Any) -> Result:
         """Simulate a PATCH request to a WSGI application.
 
         (See also: :meth:`falcon.testing.simulate_patch`)
         """
         return self.simulate_request('PATCH', path, **kwargs)
 
-    def simulate_delete(self, path='/', **kwargs) -> _ResultBase:
+    def simulate_delete(self, path: str = '/', **kwargs: Any) -> Result:
         """Simulate a DELETE request to a WSGI application.
 
         (See also: :meth:`falcon.testing.simulate_delete`)
         """
         return self.simulate_request('DELETE', path, **kwargs)
 
-    def simulate_request(self, *args, **kwargs) -> _ResultBase:
+    def simulate_request(self, *args: Any, **kwargs: Any) -> Result:
         """Simulate a request to a WSGI application.
 
         Wraps :meth:`falcon.testing.simulate_request` to perform a
@@ -2070,9 +2158,9 @@ class TestClient:
         if self._default_headers:
             # NOTE(kgriffs): Handle the case in which headers is explicitly
             # set to None.
-            additional_headers = kwargs.get('headers', {}) or {}
+            additional_headers = kwargs.get('headers') or {}
 
-            merged_headers = self._default_headers.copy()
+            merged_headers = dict(self._default_headers)
             merged_headers.update(additional_headers)
 
             kwargs['headers'] = merged_headers
@@ -2095,25 +2183,28 @@ class TestClient:
 
 
 class _AsyncContextManager:
-    def __init__(self, coro):
+    def __init__(self, coro: Awaitable[StreamedResult]):
         self._coro = coro
-        self._obj = None
+        self._obj: Optional[StreamedResult] = None
 
-    async def __aenter__(self):
+    async def __aenter__(self) -> StreamedResult:
         self._obj = await self._coro
         return self._obj
 
-    async def __aexit__(self, exc_type, exc, tb):
+    async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
+        assert self._obj is not None
         await self._obj.finalize()
         self._obj = None
 
 
 class _WSContextManager:
-    def __init__(self, ws, task_req):
+    def __init__(
+        self, ws: helpers.ASGIWebSocketSimulator, task_req: asyncio.Task
+    ) -> None:
         self._ws = ws
         self._task_req = task_req
 
-    async def __aenter__(self):
+    async def __aenter__(self) -> helpers.ASGIWebSocketSimulator:
         ready_waiter = asyncio.create_task(self._ws.wait_ready())
 
         # NOTE(kgriffs): Wait on both so that in the case that the request
@@ -2138,14 +2229,24 @@ class _WSContextManager:
 
         return self._ws
 
-    async def __aexit__(self, exc_type, exc, tb):
+    async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
         await self._ws.close()
         await self._task_req
 
 
 def _prepare_sim_args(
-    path, query_string, params, params_csv, content_type, headers, body, json, extras
-):
+    path: str,
+    query_string: Optional[str],
+    params: Optional[Mapping[str, Any]],
+    params_csv: bool,
+    content_type: Optional[str],
+    headers: Optional[HeaderArg],
+    body: Optional[Union[str, bytes]],
+    json: Optional[Any],
+    extras: Optional[Mapping[str, Any]],
+) -> Tuple[
+    str, str, Optional[HeaderArg], Optional[Union[str, bytes]], Mapping[str, Any]
+]:
     if not path.startswith('/'):
         raise ValueError("path must start with '/'")
 
@@ -2170,18 +2271,18 @@ def _prepare_sim_args(
         )
 
     if content_type is not None:
-        headers = headers or {}
+        headers = dict(headers or {})
         headers['Content-Type'] = content_type
 
     if json is not None:
         body = json_module.dumps(json, ensure_ascii=False)
-        headers = headers or {}
+        headers = dict(headers or {})
         headers['Content-Type'] = MEDIA_JSON
 
     return path, query_string, headers, body, extras
 
 
-def _is_asgi_app(app):
+def _is_asgi_app(app: Callable[..., Any]) -> bool:
     app_args = inspect.getfullargspec(app).args
     num_app_args = len(app_args)
 
@@ -2196,7 +2297,7 @@ def _is_asgi_app(app):
     return is_asgi
 
 
-async def _wait_for_startup(events):
+async def _wait_for_startup(events: Iterable[AsgiEvent]) -> None:
     # NOTE(kgriffs): This is covered, but our gate for some reason doesn't
     #   understand `while True`.
     while True:  # pragma: nocover
@@ -2213,7 +2314,7 @@ async def _wait_for_startup(events):
         await asyncio.sleep(0)
 
 
-async def _wait_for_shutdown(events):
+async def _wait_for_shutdown(events: Iterable[AsgiEvent]) -> None:
     # NOTE(kgriffs): This is covered, but our gate for some reason doesn't
     #   understand `while True`.
     while True:  # pragma: nocover
diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py
index 05513b88..97392d57 100644
--- a/falcon/testing/helpers.py
+++ b/falcon/testing/helpers.py
@@ -22,6 +22,8 @@ directly from the `testing` package::
     wsgi_environ = testing.create_environ()
 """
 
+from __future__ import annotations
+
 import asyncio
 from collections import defaultdict
 from collections import deque
@@ -36,16 +38,35 @@ import re
 import socket
 import sys
 import time
-from typing import Any, Dict, Iterable, Optional, Union
+from typing import (
+    Any,
+    Callable,
+    Deque,
+    Dict,
+    Iterable,
+    Iterator,
+    List,
+    Mapping,
+    Optional,
+    TextIO,
+    Tuple,
+    Type,
+    Union,
+)
 
 import falcon
 from falcon import errors as falcon_errors
+from falcon._typing import CookieArg
+from falcon._typing import HeaderArg
+from falcon._typing import ResponseStatus
 import falcon.asgi
+from falcon.asgi_spec import AsgiEvent
 from falcon.asgi_spec import EventType
 from falcon.asgi_spec import ScopeType
 from falcon.asgi_spec import WSCloseCode
 from falcon.constants import SINGLETON_HEADERS
 import falcon.request
+from falcon.util import code_to_http_status
 from falcon.util import uri
 from falcon.util.mediatypes import parse_header
 
@@ -73,11 +94,11 @@ class ASGILifespanEventEmitter:
             emitting the final shutdown event (``'lifespan.shutdown``).
     """
 
-    def __init__(self, shutting_down):
+    def __init__(self, shutting_down: asyncio.Condition) -> None:
         self._state = 0
         self._shutting_down = shutting_down
 
-    async def emit(self):
+    async def emit(self) -> AsgiEvent:
         if self._state == 0:
             self._state += 1
             return {'type': EventType.LIFESPAN_STARTUP}
@@ -124,22 +145,18 @@ class ASGIRequestEventEmitter:
             ``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
-            connection is in a "disconnected" state.
     """
 
     # TODO(kgriffs): If this pattern later becomes useful elsewhere,
     #   factor out into a standalone helper class.
-    _branch_decider = defaultdict(bool)  # type: defaultdict
+    _branch_decider: dict[str, bool] = defaultdict(bool)
 
     def __init__(
         self,
         body: Optional[Union[str, bytes]] = None,
         chunk_size: Optional[int] = None,
         disconnect_at: Optional[Union[int, float]] = None,
-    ):
+    ) -> None:
         if body is None:
             body = b''
         elif not isinstance(body, bytes):
@@ -153,7 +170,7 @@ class ASGIRequestEventEmitter:
         if chunk_size is None:
             chunk_size = 4096
 
-        self._body = body  # type: Optional[memoryview]
+        self._body: Optional[memoryview] = body
         self._chunk_size = chunk_size
         self._emit_empty_chunks = True
         self._disconnect_at = disconnect_at
@@ -164,10 +181,13 @@ class ASGIRequestEventEmitter:
         self._emitted_empty_chunk_b = False
 
     @property
-    def disconnected(self):
+    def disconnected(self) -> bool:
+        """Returns ``True`` if the simulated client connection is in a
+        "disconnected" state.
+        """  # noqa: D205
         return self._disconnected or (self._disconnect_at <= time.time())
 
-    def disconnect(self, exhaust_body: Optional[bool] = None):
+    def disconnect(self, exhaust_body: Optional[bool] = None) -> None:
         """Set the client connection state to disconnected.
 
         Call this method to simulate an immediate client disconnect and
@@ -184,7 +204,7 @@ class ASGIRequestEventEmitter:
 
         self._disconnected = True
 
-    async def emit(self) -> Dict[str, Any]:
+    async def emit(self) -> AsgiEvent:
         # NOTE(kgriffs): Special case: if we are immediately disconnected,
         #   the first event should be 'http.disconnnect'
         if self._disconnect_at == 0:
@@ -208,7 +228,7 @@ class ASGIRequestEventEmitter:
 
             return {'type': EventType.HTTP_DISCONNECT}
 
-        event = {'type': EventType.HTTP_REQUEST}  # type: Dict[str, Any]
+        event: Dict[str, Any] = {'type': EventType.HTTP_REQUEST}
 
         if self._emit_empty_chunks:
             # NOTE(kgriffs): Return a couple variations on empty chunks
@@ -264,7 +284,7 @@ class ASGIRequestEventEmitter:
 
     __call__ = emit
 
-    def _toggle_branch(self, name: str):
+    def _toggle_branch(self, name: str) -> bool:
         self._branch_decider[name] = not self._branch_decider[name]
         return self._branch_decider[name]
 
@@ -272,20 +292,6 @@ class ASGIRequestEventEmitter:
 class ASGIResponseEventCollector:
     """Collects and validates ASGI events returned by an app.
 
-    Attributes:
-        events (iterable): An iterable of events that were emitted by
-            the app, collected as-is from the app.
-        headers (iterable): An iterable of (str, str) tuples representing
-            the ISO-8859-1 decoded headers emitted by the app in the body of
-            the ``'http.response.start'`` event.
-        status (int): HTTP status code emitted by the app in the body of
-            the ``'http.response.start'`` event.
-        body_chunks (iterable): An iterable of ``bytes`` objects emitted
-            by the app via ``'http.response.body'`` events.
-        more_body (bool): Whether or not the app expects to emit more
-            body chunks. Will be ``None`` if unknown (i.e., the app has
-            not yet emitted any ``'http.response.body'`` events.)
-
     Raises:
         TypeError: An event field emitted by the app was of an unexpected type.
         ValueError: Invalid event name or field value.
@@ -303,14 +309,37 @@ class ASGIResponseEventCollector:
     _HEADER_NAME_RE = re.compile(rb'^[a-zA-Z][a-zA-Z0-9\-_]*$')
     _BAD_HEADER_VALUE_RE = re.compile(rb'[\000-\037]')
 
-    def __init__(self):
+    events: List[AsgiEvent]
+    """An iterable of events that were emitted by the app,
+    collected as-is from the app.
+    """
+    headers: List[Tuple[str, str]]
+    """An iterable of (str, str) tuples representing the ISO-8859-1 decoded
+    headers emitted by the app in the body of the ``'http.response.start'`` event.
+    """
+    status: Optional[ResponseStatus]
+    """HTTP status code emitted by the app in the body of the
+    ``'http.response.start'`` event.
+    """
+    body_chunks: List[bytes]
+    """An iterable of ``bytes`` objects emitted by the app via
+    ``'http.response.body'`` events.
+    """
+    more_body: Optional[bool]
+    """Whether or not the app expects to emit more body chunks.
+
+    Will be ``None`` if unknown (i.e., the app has not yet emitted
+    any ``'http.response.body'`` events.)
+    """
+
+    def __init__(self) -> None:
         self.events = []
         self.headers = []
         self.status = None
         self.body_chunks = []
         self.more_body = None
 
-    async def collect(self, event: Dict[str, Any]):
+    async def collect(self, event: AsgiEvent) -> None:
         if self.more_body is False:
             # NOTE(kgriffs): According to the ASGI spec, once we get a
             #   message setting more_body to False, any further messages
@@ -387,66 +416,69 @@ class ASGIWebSocketSimulator:
         The ASGIWebSocketSimulator class is not designed to be instantiated
         directly; rather it should be obtained via
         :meth:`~falcon.testing.ASGIConductor.simulate_ws`.
-
-    Attributes:
-        ready (bool): ``True`` if the WebSocket connection has been
-            accepted and the client is still connected, ``False`` otherwise.
-        closed (bool): ``True`` if the WebSocket connection has been
-            denied or closed by the app, or the client has disconnected.
-        close_code (int): The WebSocket close code provided by the app if
-            the connection is closed, or ``None`` if the connection is open.
-        close_reason (str): The WebSocket close reason provided by the app if
-            the connection is closed, or ``None`` if the connection is open.
-        subprotocol (str): The subprotocol the app wishes to accept, or
-            ``None`` if not specified.
-        headers (Iterable[Iterable[bytes]]): An iterable of ``[name, value]``
-            two-item iterables, where *name* is the header name, and *value* is
-            the header value for each header returned by the app when
-            it accepted the WebSocket connection. This property resolves to
-            ``None`` if the connection has not been accepted.
     """
 
     _DEFAULT_WAIT_READY_TIMEOUT = 5
 
-    def __init__(self):
+    def __init__(self) -> None:
         self.__msgpack = None
 
         self._state = _WebSocketState.CONNECT
         self._disconnect_emitted = False
-        self._close_code = None
-        self._close_reason = None
-        self._accepted_subprotocol = None
-        self._accepted_headers = None
-        self._collected_server_events = deque()
-        self._collected_client_events = deque()
+        self._close_code: Optional[int] = None
+        self._close_reason: Optional[str] = None
+        self._accepted_subprotocol: Optional[str] = None
+        self._accepted_headers: Optional[List[Tuple[bytes, bytes]]] = None
+        self._collected_server_events: Deque[AsgiEvent] = deque()
+        self._collected_client_events: Deque[AsgiEvent] = deque()
 
         self._event_handshake_complete = asyncio.Event()
 
     @property
     def ready(self) -> bool:
+        """``True`` if the WebSocket connection has been accepted and the client is
+        still connected, ``False`` otherwise.
+        """  # noqa: D205
         return self._state == _WebSocketState.ACCEPTED
 
     @property
     def closed(self) -> bool:
+        """``True`` if the WebSocket connection has been denied or closed by the app,
+        or the client has disconnected.
+        """  # noqa: D205
         return self._state in {_WebSocketState.DENIED, _WebSocketState.CLOSED}
 
     @property
-    def close_code(self) -> int:
+    def close_code(self) -> Optional[int]:
+        """The WebSocket close code provided by the app if the connection is closed.
+
+        Returns ``None`` if the connection is still open.
+        """
         return self._close_code
 
     @property
-    def close_reason(self) -> str:
+    def close_reason(self) -> Optional[str]:
+        """The WebSocket close reason provided by the app if the connection is closed.
+
+        Returns ``None`` if the connection is still open.
+        """
         return self._close_reason
 
     @property
-    def subprotocol(self) -> str:
+    def subprotocol(self) -> Optional[str]:
+        """The subprotocol the app wishes to accept, or ``None`` if not specified."""
         return self._accepted_subprotocol
 
     @property
-    def headers(self) -> Iterable[Iterable[bytes]]:
+    def headers(self) -> Optional[List[Tuple[bytes, bytes]]]:
+        """An iterable of ``[name, value]`` two-item tuples, where *name* is the
+        header name, and *value* is the header value for each header returned by
+        the app when it accepted the WebSocket connection.
+        This property resolves to ``None`` if the connection has not been accepted.
+        """  # noqa: D205
         return self._accepted_headers
 
-    async def wait_ready(self, timeout: Optional[int] = None):
+    async def wait_ready(self, timeout: Optional[int] = None) -> None:
         """Wait until the connection has been accepted or denied.
 
         This coroutine can be awaited in order to pause execution until the
@@ -476,7 +508,9 @@ class ASGIWebSocketSimulator:
     # NOTE(kgriffs): This is a coroutine just in case we need it to be
     #   in a future code revision. It also makes it more consistent
     #   with the other methods.
-    async def close(self, code: Optional[int] = None, reason: Optional[str] = None):
+    async def close(
+        self, code: Optional[int] = None, reason: Optional[str] = None
+    ) -> None:
         """Close the simulated connection.
 
         Keyword Args:
@@ -509,7 +543,7 @@ class ASGIWebSocketSimulator:
         self._close_code = code
         self._close_reason = reason
 
-    async def send_text(self, payload: str):
+    async def send_text(self, payload: str) -> None:
         """Send a message to the app with a Unicode string payload.
 
         Arguments:
@@ -526,7 +560,7 @@ class ASGIWebSocketSimulator:
         #   but the server will be expecting websocket.receive
         await self._send(text=payload)
 
-    async def send_data(self, payload: Union[bytes, bytearray, memoryview]):
+    async def send_data(self, payload: Union[bytes, bytearray, memoryview]) -> None:
         """Send a message to the app with a binary data payload.
 
         Arguments:
@@ -543,7 +577,7 @@ class ASGIWebSocketSimulator:
         #   but the server will be expecting websocket.receive
         await self._send(data=bytes(payload))
 
-    async def send_json(self, media: object):
+    async def send_json(self, media: object) -> None:
         """Send a message to the app with a JSON-encoded payload.
 
         Arguments:
@@ -553,7 +587,7 @@ class ASGIWebSocketSimulator:
         text = json.dumps(media)
         await self.send_text(text)
 
-    async def send_msgpack(self, media: object):
+    async def send_msgpack(self, media: object) -> None:
         """Send a message to the app with a MessagePack-encoded payload.
 
         Arguments:
@@ -611,7 +645,7 @@ class ASGIWebSocketSimulator:
 
         return data
 
-    async def receive_json(self) -> object:
+    async def receive_json(self) -> Any:
         """Receive a message from the app with a JSON-encoded TEXT payload.
 
         Awaiting this coroutine will block until a message is available or
@@ -621,7 +655,7 @@ class ASGIWebSocketSimulator:
         text = await self.receive_text()
         return json.loads(text)
 
-    async def receive_msgpack(self) -> object:
+    async def receive_msgpack(self) -> Any:
         """Receive a message from the app with a MessagePack-encoded BINARY payload.
 
         Awaiting this coroutine will block until a message is available or
@@ -632,7 +666,7 @@ class ASGIWebSocketSimulator:
         return self._msgpack.unpackb(data, use_list=True, raw=False)
 
     @property
-    def _msgpack(self):
+    def _msgpack(self) -> Any:
         # NOTE(kgriffs): A property is used in lieu of referencing
         #   the msgpack module directly, in order to bubble up the
         #   import error in an obvious way, when the package has
@@ -645,7 +679,7 @@ class ASGIWebSocketSimulator:
 
         return self.__msgpack
 
-    def _require_accepted(self):
+    def _require_accepted(self) -> None:
         if self._state == _WebSocketState.ACCEPTED:
             return
 
@@ -672,13 +706,14 @@ class ASGIWebSocketSimulator:
     # NOTE(kgriffs): This is a coroutine just in case we need it to be
     #   in a future code revision. It also makes it more consistent
     #   with the other methods.
-    async def _send(self, data: Optional[bytes] = None, text: Optional[str] = None):
+    async def _send(
+        self, data: Optional[bytes] = None, text: Optional[str] = None
+    ) -> None:
         self._require_accepted()
 
         # NOTE(kgriffs): From the client's perspective, it was a send,
         #   but the server will be expecting websocket.receive
-        event = {'type': EventType.WS_RECEIVE}  # type: Dict[str, Union[bytes, str]]
-
+        event: Dict[str, Any] = {'type': EventType.WS_RECEIVE}
         if data is not None:
             event['bytes'] = data
 
@@ -692,7 +727,7 @@ class ASGIWebSocketSimulator:
         #   like it's 1992.)
         await asyncio.sleep(0)
 
-    async def _receive(self) -> Dict[str, Any]:
+    async def _receive(self) -> AsgiEvent:
         while not self._collected_server_events:
             self._require_accepted()
             await asyncio.sleep(0)
@@ -700,7 +735,7 @@ class ASGIWebSocketSimulator:
         self._require_accepted()
         return self._collected_server_events.popleft()
 
-    async def _emit(self) -> Dict[str, Any]:
+    async def _emit(self) -> AsgiEvent:
         if self._state == _WebSocketState.CONNECT:
             self._state = _WebSocketState.HANDSHAKE
             return {'type': EventType.WS_CONNECT}
@@ -717,7 +752,7 @@ class ASGIWebSocketSimulator:
 
         return self._collected_client_events.popleft()
 
-    async def _collect(self, event: Dict[str, Any]):
+    async def _collect(self, event: AsgiEvent) -> None:
         assert event
 
         if self._state == _WebSocketState.CONNECT:
@@ -761,7 +796,7 @@ class ASGIWebSocketSimulator:
                     #   connection is closed with a 403 and there is no websocket
                     #   close code).
                     self._close_code = WSCloseCode.FORBIDDEN
-                    self._close_reason = falcon.util.code_to_http_status(
+                    self._close_reason = code_to_http_status(
                         WSCloseCode.FORBIDDEN - 3000
                     )
 
@@ -794,7 +829,7 @@ class ASGIWebSocketSimulator:
         #   collected data/text event a chance to progress.
         await asyncio.sleep(0)
 
-    def _create_checked_disconnect(self) -> Dict[str, Any]:
+    def _create_checked_disconnect(self) -> AsgiEvent:
         if self._disconnect_emitted:
             raise falcon_errors.OperationNotAllowed(
                 'The websocket.disconnect event has already been emitted, '
@@ -814,7 +849,7 @@ class ASGIWebSocketSimulator:
 
 # get_encoding_from_headers() is Copyright 2016 Kenneth Reitz, and is
 # used here under the terms of the Apache License, Version 2.0.
-def get_encoding_from_headers(headers):
+def get_encoding_from_headers(headers: Mapping[str, str]) -> Optional[str]:
     """Return encoding from given HTTP Header Dict.
 
     Args:
@@ -861,7 +896,7 @@ def get_unused_port() -> int:
         return s.getsockname()[1]
 
 
-def rand_string(min, max) -> str:
+def rand_string(min: int, max: int) -> str:
     """Return a randomly-generated string, of a random length.
 
     Args:
@@ -876,19 +911,19 @@ def rand_string(min, max) -> str:
 
 
 def create_scope(
-    path='/',
-    query_string='',
-    method='GET',
-    headers=None,
-    host=DEFAULT_HOST,
-    scheme=None,
-    port=None,
-    http_version='1.1',
-    remote_addr=None,
-    root_path=None,
-    content_length=None,
-    include_server=True,
-    cookies=None,
+    path: str = '/',
+    query_string: str = '',
+    method: str = 'GET',
+    headers: Optional[HeaderArg] = None,
+    host: str = DEFAULT_HOST,
+    scheme: Optional[str] = None,
+    port: Optional[int] = None,
+    http_version: str = '1.1',
+    remote_addr: Optional[str] = None,
+    root_path: Optional[str] = None,
+    content_length: Optional[int] = None,
+    include_server: bool = True,
+    cookies: Optional[CookieArg] = None,
 ) -> Dict[str, Any]:
     """Create a mock ASGI scope ``dict`` for simulating HTTP requests.
 
@@ -946,12 +981,12 @@ def create_scope(
     path = uri.decode(path, unquote_plus=False)
 
     # NOTE(kgriffs): Handles both None and ''
-    query_string = query_string.encode() if query_string else b''
+    query_string_bytes = query_string.encode() if query_string else b''
 
-    if query_string and query_string.startswith(b'?'):
+    if query_string_bytes and query_string_bytes.startswith(b'?'):
         raise ValueError("query_string should not start with '?'")
 
-    scope = {
+    scope: Dict[str, Any] = {
         'type': ScopeType.HTTP,
         'asgi': {
             'version': '3.0',
@@ -960,7 +995,7 @@ def create_scope(
         'http_version': http_version,
         'method': method.upper(),
         'path': path,
-        'query_string': query_string,
+        'query_string': query_string_bytes,
     }
 
     # NOTE(kgriffs): Explicitly test against None so that the caller
@@ -1014,18 +1049,18 @@ def create_scope(
 
 
 def create_scope_ws(
-    path='/',
-    query_string='',
-    headers=None,
-    host=DEFAULT_HOST,
-    scheme=None,
-    port=None,
-    http_version='1.1',
-    remote_addr=None,
-    root_path=None,
-    include_server=True,
-    subprotocols=None,
-    spec_version='2.1',
+    path: str = '/',
+    query_string: str = '',
+    headers: Optional[HeaderArg] = None,
+    host: str = DEFAULT_HOST,
+    scheme: Optional[str] = None,
+    port: Optional[int] = None,
+    http_version: str = '1.1',
+    remote_addr: Optional[str] = None,
+    root_path: Optional[str] = None,
+    include_server: bool = True,
+    subprotocols: Optional[str] = None,
+    spec_version: str = '2.1',
 ) -> Dict[str, Any]:
     """Create a mock ASGI scope ``dict`` for simulating WebSocket requests.
 
@@ -1097,21 +1132,21 @@ def create_scope_ws(
 
 
 def create_environ(
-    path='/',
-    query_string='',
-    http_version='1.1',
-    scheme='http',
-    host=DEFAULT_HOST,
-    port=None,
-    headers=None,
-    app=None,
-    body='',
-    method='GET',
-    wsgierrors=None,
-    file_wrapper=None,
-    remote_addr=None,
-    root_path=None,
-    cookies=None,
+    path: str = '/',
+    query_string: str = '',
+    http_version: str = '1.1',
+    scheme: str = 'http',
+    host: str = DEFAULT_HOST,
+    port: Optional[int] = None,
+    headers: Optional[HeaderArg] = None,
+    app: Optional[str] = None,
+    body: Union[str, bytes] = b'',
+    method: str = 'GET',
+    wsgierrors: Optional[TextIO] = None,
+    file_wrapper: Optional[Callable[..., Any]] = None,
+    remote_addr: Optional[str] = None,
+    root_path: Optional[str] = None,
+    cookies: Optional[CookieArg] = None,
 ) -> Dict[str, Any]:
     """Create a mock PEP-3333 environ ``dict`` for simulating WSGI requests.
 
@@ -1146,7 +1181,7 @@ def create_environ(
                     f'falcon-client/{falcon.__version__}'
 
         root_path (str): Value for the ``SCRIPT_NAME`` environ variable, described in
-            PEP-333: 'The initial portion of the request URL's "path" that
+            PEP-3333: 'The initial portion of the request URL's "path" that
             corresponds to the application object, so that the application
             knows its virtual "location". This may be an empty string, if the
             application corresponds to the "root" of the server.' (default ``''``)
@@ -1174,7 +1209,7 @@ def create_environ(
     if query_string and query_string.startswith('?'):
         raise ValueError("query_string should not start with '?'")
 
-    body = io.BytesIO(body.encode() if isinstance(body, str) else body)
+    body_bytes = io.BytesIO(body.encode() if isinstance(body, str) else body)
 
     # NOTE(kgriffs): wsgiref, gunicorn, and uWSGI all unescape
     # the paths before setting PATH_INFO but preserve raw original
@@ -1199,11 +1234,11 @@ def create_environ(
 
     scheme = scheme.lower()
     if port is None:
-        port = '80' if scheme == 'http' else '443'
+        port_str = '80' if scheme == 'http' else '443'
     else:
         # NOTE(kgriffs): Running it through int() first ensures that if
         #   a string was passed, it is a valid integer.
-        port = str(int(port))
+        port_str = str(int(port))
 
     root_path = root_path or app or ''
 
@@ -1213,7 +1248,7 @@ def create_environ(
     if root_path and not root_path.startswith('/'):
         root_path = '/' + root_path
 
-    env = {
+    env: dict[str, Any] = {
         'SERVER_PROTOCOL': 'HTTP/' + http_version,
         'SERVER_SOFTWARE': 'gunicorn/0.17.0',
         'SCRIPT_NAME': (root_path or ''),
@@ -1223,10 +1258,10 @@ def create_environ(
         'REMOTE_PORT': '65133',
         'RAW_URI': raw_path,
         'SERVER_NAME': host,
-        'SERVER_PORT': port,
+        'SERVER_PORT': port_str,
         'wsgi.version': (1, 0),
         'wsgi.url_scheme': scheme,
-        'wsgi.input': body,
+        'wsgi.input': body_bytes,
         'wsgi.errors': wsgierrors or sys.stderr,
         'wsgi.multithread': False,
         'wsgi.multiprocess': True,
@@ -1246,16 +1281,16 @@ def create_environ(
         host_header = host
 
         if scheme == 'https':
-            if port != '443':
-                host_header += ':' + port
+            if port_str != '443':
+                host_header += ':' + port_str
         else:
-            if port != '80':
-                host_header += ':' + port
+            if port_str != '80':
+                host_header += ':' + port_str
 
         env['HTTP_HOST'] = host_header
 
-    content_length = body.seek(0, 2)
-    body.seek(0)
+    content_length = body_bytes.seek(0, 2)
+    body_bytes.seek(0)
 
     if content_length != 0:
         env['CONTENT_LENGTH'] = str(content_length)
@@ -1270,7 +1305,9 @@ def create_environ(
     return env
 
 
-def create_req(options=None, **kwargs) -> falcon.Request:
+def create_req(
+    options: Optional[falcon.request.RequestOptions] = None, **kwargs: Any
+) -> falcon.Request:
     """Create and return a new Request instance.
 
     This function can be used to conveniently create a WSGI environ
@@ -1288,7 +1325,10 @@ def create_req(options=None, **kwargs) -> falcon.Request:
 
 
 def create_asgi_req(
-    body=None, req_type=None, options=None, **kwargs
+    body: Optional[bytes] = None,
+    req_type: Optional[Type[falcon.asgi.Request]] = None,
+    options: Optional[falcon.request.RequestOptions] = None,
+    **kwargs: Any,
 ) -> falcon.asgi.Request:
     """Create and return a new ASGI Request instance.
 
@@ -1324,7 +1364,9 @@ def create_asgi_req(
 
 
 @contextlib.contextmanager
-def redirected(stdout=sys.stdout, stderr=sys.stderr):
+def redirected(
+    stdout: TextIO = sys.stdout, stderr: TextIO = sys.stderr
+) -> Iterator[None]:
     """Redirect stdout or stderr temporarily.
 
     e.g.:
@@ -1341,7 +1383,7 @@ def redirected(stdout=sys.stdout, stderr=sys.stderr):
         sys.stderr, sys.stdout = old_stderr, old_stdout
 
 
-def closed_wsgi_iterable(iterable):
+def closed_wsgi_iterable(iterable: Iterable[bytes]) -> Iterable[bytes]:
     """Wrap an iterable to ensure its ``close()`` method is called.
 
     Wraps the given `iterable` in an iterator utilizing a ``for`` loop as
@@ -1364,7 +1406,7 @@ def closed_wsgi_iterable(iterable):
         iterator: An iterator yielding the same bytestrings as `iterable`
     """
 
-    def wrapper():
+    def wrapper() -> Iterator[bytes]:
         try:
             for item in iterable:
                 yield item
@@ -1373,6 +1415,7 @@ def closed_wsgi_iterable(iterable):
                 iterable.close()
 
     wrapped = wrapper()
+    head: Tuple[bytes, ...]
     try:
         head = (next(wrapped),)
     except StopIteration:
@@ -1385,10 +1428,10 @@ def closed_wsgi_iterable(iterable):
 # ---------------------------------------------------------------------
 
 
-def _add_headers_to_environ(env, headers):
+def _add_headers_to_environ(env: Dict[str, Any], headers: Optional[HeaderArg]) -> None:
     if headers:
         try:
-            items = headers.items()
+            items = headers.items()  # type: ignore[union-attr]
         except AttributeError:
             items = headers
 
@@ -1411,14 +1454,21 @@ def _add_headers_to_environ(env, headers):
 
 
 def _add_headers_to_scope(
-    scope, headers, content_length, host, port, scheme, http_version, cookies
-):
+    scope: dict[str, Any],
+    headers: Optional[HeaderArg],
+    content_length: Optional[int],
+    host: str,
+    port: int,
+    scheme: Optional[str],
+    http_version: str,
+    cookies: Optional[CookieArg],
+) -> None:
     found_ua = False
-    prepared_headers = []
+    prepared_headers: List[Iterable[bytes]] = []
 
     if headers:
         try:
-            items = headers.items()
+            items = headers.items()  # type: ignore[union-attr]
         except AttributeError:
             items = headers
 
@@ -1461,7 +1511,7 @@ def _add_headers_to_scope(
     scope['headers'] = iter(prepared_headers)
 
 
-def _fixup_http_version(http_version) -> str:
+def _fixup_http_version(http_version: str) -> str:
     if http_version not in ('2', '2.0', '1.1', '1.0', '1'):
         raise ValueError('Invalid http_version specified: ' + http_version)
 
@@ -1475,7 +1525,7 @@ def _fixup_http_version(http_version) -> str:
     return http_version
 
 
-def _make_cookie_values(cookies: Dict) -> str:
+def _make_cookie_values(cookies: CookieArg) -> str:
     return '; '.join(
         [
             '{}={}'.format(key, cookie.value if hasattr(cookie, 'value') else cookie)
diff --git a/falcon/testing/resource.py b/falcon/testing/resource.py
index cb1715bc..2728dc2e 100644
--- a/falcon/testing/resource.py
+++ b/falcon/testing/resource.py
@@ -32,9 +32,9 @@ import falcon
 
 if typing.TYPE_CHECKING:  # pragma: no cover
     from falcon import app as wsgi
+    from falcon._typing import HeaderArg
+    from falcon._typing import Resource
     from falcon.asgi import app as asgi
-    from falcon.typing import HeaderList
-    from falcon.typing import Resource
 
 
 def capture_responder_args(
@@ -164,16 +164,28 @@ class SimpleTestResource:
             *json* or *body* may be specified, but not both.
         headers (dict): Default set of additional headers to include in
             responses
+    """
+
+    captured_req: typing.Optional[typing.Union[wsgi.Request, asgi.Request]]
+    """The last Request object passed into any one of the responder methods."""
+    captured_resp: typing.Optional[typing.Union[wsgi.Response, asgi.Response]]
+    """The last Response object passed into any one of the responder methods."""
+
+    captured_kwargs: typing.Optional[typing.Any]
+    """The last dictionary of kwargs, beyond ``req`` and ``resp``, that were
+    passed into any one of the responder methods."""
+
+    captured_req_media: typing.Optional[typing.Any]
+    """The last Request media provided to any one of the responder methods.
 
-    Attributes:
-        called (bool): Whether or not a req/resp was captured.
-        captured_req (falcon.Request): The last Request object passed
-            into any one of the responder methods.
-        captured_resp (falcon.Response): The last Response object passed
-            into any one of the responder methods.
-        captured_kwargs (dict): The last dictionary of kwargs, beyond
-            ``req`` and ``resp``, that were passed into any one of the
-            responder methods.
+    This value is only captured when the ``'capture-req-media'`` header is
+    set on the request.
+    """
+    captured_req_body: typing.Optional[bytes]
+    """The last Request body provided to any one of the responder methods.
+
+    This value is only captured when the ``'capture-req-body-bytes'`` header is
+    set on the request. The value of the header is the number of bytes to read.
     """
 
     def __init__(
@@ -181,8 +193,8 @@ class SimpleTestResource:
         status: typing.Optional[str] = None,
         body: typing.Optional[str] = None,
         json: typing.Optional[dict[str, str]] = None,
-        headers: typing.Optional[HeaderList] = None,
-    ):
+        headers: typing.Optional[HeaderArg] = None,
+    ) -> None:
         self._default_status = status
         self._default_headers = headers
 
@@ -198,18 +210,15 @@ class SimpleTestResource:
         else:
             self._default_body = body
 
-        self.captured_req: typing.Optional[typing.Union[wsgi.Request, asgi.Request]] = (
-            None
-        )
-        self.captured_resp: typing.Optional[
-            typing.Union[wsgi.Response, asgi.Response]
-        ] = None
-        self.captured_kwargs: typing.Optional[typing.Any] = None
-        self.captured_req_media: typing.Optional[typing.Any] = None
-        self.captured_req_body: typing.Optional[bytes] = None
+        self.captured_req = None
+        self.captured_resp = None
+        self.captured_kwargs = None
+        self.captured_req_media = None
+        self.captured_req_body = None
 
     @property
-    def called(self):
+    def called(self) -> bool:
+        """Whether or not a req/resp was captured."""
         return self.captured_req is not None
 
     @falcon.before(capture_responder_args)
@@ -253,16 +262,6 @@ class SimpleTestResourceAsync(SimpleTestResource):
             *json* or *body* may be specified, but not both.
         headers (dict): Default set of additional headers to include in
             responses
-
-    Attributes:
-        called (bool): Whether or not a req/resp was captured.
-        captured_req (falcon.Request): The last Request object passed
-            into any one of the responder methods.
-        captured_resp (falcon.Response): The last Response object passed
-            into any one of the responder methods.
-        captured_kwargs (dict): The last dictionary of kwargs, beyond
-            ``req`` and ``resp``, that were passed into any one of the
-            responder methods.
     """
 
     @falcon.before(capture_responder_args_async)
diff --git a/falcon/testing/srmock.py b/falcon/testing/srmock.py
index 389b8574..6e38236f 100644
--- a/falcon/testing/srmock.py
+++ b/falcon/testing/srmock.py
@@ -18,30 +18,37 @@ This module implements a callable StartResponseMock class that can be
 used, along with a mock environ dict, to simulate a WSGI request.
 """
 
+from __future__ import annotations
+
+from typing import Any, Optional
+
 from falcon import util
+from falcon._typing import HeaderIter
+from falcon.typing import Headers
 
 
 class StartResponseMock:
-    """Mock object representing a WSGI `start_response` callable.
-
-    Attributes:
-        call_count (int): Number of times `start_response` was called.
-        status (str): HTTP status line, e.g. '785 TPS Cover Sheet
-            not attached'.
-        headers (list): Raw headers list passed to `start_response`,
-            per PEP-333.
-        headers_dict (dict): Headers as a case-insensitive
-            ``dict``-like object, instead of a ``list``.
+    """Mock object representing a WSGI `start_response` callable."""
 
-    """
+    status: Optional[str]
+    """HTTP status line, e.g. '785 TPS Cover Sheet not attached'."""
+    headers: Optional[HeaderIter]
+    """Raw headers list passed to `start_response`, per PEP-3333."""
+    headers_dict: Headers
+    """Headers as a case-insensitive ``dict``-like object, instead of a ``list``."""
 
-    def __init__(self):
+    def __init__(self) -> None:
         self._called = 0
         self.status = None
         self.headers = None
-        self.exc_info = None
+        self.exc_info: Optional[Any] = None
 
-    def __call__(self, status, headers, exc_info=None):
+    def __call__(
+        self,
+        status: str,
+        headers: HeaderIter,
+        exc_info: Optional[Any] = None,
+    ) -> Any:
         """Implement the PEP-3333 `start_response` protocol."""
 
         self._called += 1
@@ -52,9 +59,10 @@ class StartResponseMock:
         # worry about the case-insensitive nature of header names.
         self.headers = [(name.lower(), value) for name, value in headers]
 
-        self.headers_dict = util.CaseInsensitiveDict(headers)
+        self.headers_dict = util.CaseInsensitiveDict(headers)  # type: ignore[assignment]
         self.exc_info = exc_info
 
     @property
-    def call_count(self):
+    def call_count(self) -> int:
+        """Number of times `start_response` was called."""
         return self._called
diff --git a/falcon/testing/test_case.py b/falcon/testing/test_case.py
index 1cb95328..632b662b 100644
--- a/falcon/testing/test_case.py
+++ b/falcon/testing/test_case.py
@@ -45,40 +45,41 @@ class TestCase(unittest.TestCase, TestClient):
 
     Simply inherit from this class in your test case classes instead of
     :class:`unittest.TestCase` or :class:`testtools.TestCase`.
+    """
 
-    Attributes:
-        app (object): A WSGI or ASGI application to target when simulating
-            requests (defaults to ``falcon.App()``). When testing your
-            application, you will need to set this to your own instance
-            of :class:`falcon.App` or :class:`falcon.asgi.App`. For
-            example::
+    # NOTE(vytas): Here we have to restore __test__ to allow collecting tests!
+    __test__ = True
 
-                from falcon import testing
-                import myapp
+    app: falcon.App
+    """A WSGI or ASGI application to target when simulating
+    requests (defaults to ``falcon.App()``). When testing your
+    application, you will need to set this to your own instance
+    of :class:`falcon.App` or :class:`falcon.asgi.App`. For
+    example::
 
+        from falcon import testing
+        import myapp
 
-                class MyTestCase(testing.TestCase):
-                    def setUp(self):
-                        super(MyTestCase, self).setUp()
 
-                        # Assume the hypothetical `myapp` package has a
-                        # function called `create()` to initialize and
-                        # return a `falcon.App` instance.
-                        self.app = myapp.create()
+        class MyTestCase(testing.TestCase):
+            def setUp(self):
+                super(MyTestCase, self).setUp()
 
+                # Assume the hypothetical `myapp` package has a
+                # function called `create()` to initialize and
+                # return a `falcon.App` instance.
+                self.app = myapp.create()
 
-                class TestMyApp(MyTestCase):
-                    def test_get_message(self):
-                        doc = {'message': 'Hello world!'}
 
-                        result = self.simulate_get('/messages/42')
-                        self.assertEqual(result.json, doc)
-    """
+        class TestMyApp(MyTestCase):
+            def test_get_message(self):
+                doc = {'message': 'Hello world!'}
 
-    # NOTE(vytas): Here we have to restore __test__ to allow collecting tests!
-    __test__ = True
+                result = self.simulate_get('/messages/42')
+                self.assertEqual(result.json, doc)
+    """
 
-    def setUp(self):
+    def setUp(self) -> None:
         super(TestCase, self).setUp()
 
         app = falcon.App()
diff --git a/falcon/typing.py b/falcon/typing.py
index a548b32a..1eadeee9 100644
--- a/falcon/typing.py
+++ b/falcon/typing.py
@@ -1,4 +1,4 @@
-# Copyright 2021-2023 by Vytautas Liuolia.
+# Copyright 2024 by Federico Caselli
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -11,212 +11,35 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-"""Shorthand definitions for more complex types."""
+"""Module that defines public Falcon type definitions."""
 
 from __future__ import annotations
 
-from enum import auto
-from enum import Enum
-import http
-import sys
-from typing import (
-    Any,
-    AsyncIterator,
-    Awaitable,
-    Callable,
-    Dict,
-    List,
-    Literal,
-    Optional,
-    Pattern,
-    Protocol,
-    Tuple,
-    TYPE_CHECKING,
-    TypeVar,
-    Union,
-)
-
-# NOTE(vytas): Mypy still struggles to handle a conditional import in the EAFP
-#   fashion, so we branch on Py version instead (which it does understand).
-if sys.version_info >= (3, 11):
-    from wsgiref.types import StartResponse as StartResponse
-    from wsgiref.types import WSGIEnvironment as WSGIEnvironment
-else:
-    WSGIEnvironment = Dict[str, Any]
-    StartResponse = Callable[[str, List[Tuple[str, str]]], Callable[[bytes], None]]
+from typing import AsyncIterator, Dict, Optional, Protocol, TYPE_CHECKING
 
 if TYPE_CHECKING:
-    from falcon.asgi import Request as AsgiRequest
-    from falcon.asgi import Response as AsgiResponse
     from falcon.asgi import SSEvent
-    from falcon.asgi import WebSocket
-    from falcon.asgi_spec import AsgiEvent
-    from falcon.asgi_spec import AsgiSendMsg
-    from falcon.http_error import HTTPError
-    from falcon.request import Request
-    from falcon.response import Response
-
-
-class _Missing(Enum):
-    MISSING = auto()
-
-
-_T = TypeVar('_T')
-MISSING = _Missing.MISSING
-MissingOr = Union[Literal[_Missing.MISSING], _T]
-
-Link = Dict[str, str]
-
-# Error handlers
-ErrorHandler = Callable[['Request', 'Response', BaseException, Dict[str, Any]], None]
-
-
-class AsgiErrorHandler(Protocol):
-    async def __call__(
-        self,
-        req: AsgiRequest,
-        resp: Optional[AsgiResponse],
-        error: BaseException,
-        params: Dict[str, Any],
-        *,
-        ws: Optional[WebSocket] = ...,
-    ) -> None: ...
-
-
-# Error serializers
-ErrorSerializer = Callable[['Request', 'Response', 'HTTPError'], None]
 
-JSONSerializable = Union[
-    Dict[str, 'JSONSerializable'],
-    List['JSONSerializable'],
-    Tuple['JSONSerializable', ...],
-    bool,
-    float,
-    int,
-    str,
-    None,
-]
-
-# Sinks
-SinkPrefix = Union[str, Pattern[str]]
-
-
-class SinkCallable(Protocol):
-    def __call__(self, req: Request, resp: Response, **kwargs: str) -> None: ...
-
-
-class AsgiSinkCallable(Protocol):
-    async def __call__(
-        self, req: AsgiRequest, resp: AsgiResponse, **kwargs: str
-    ) -> None: ...
-
-
-# TODO(vytas): Is it possible to specify a Callable or a Protocol that defines
-#   type hints for the two first parameters, but accepts any number of keyword
-#   arguments afterwords?
-# class SinkCallable(Protocol):
-#     def __call__(sef, req: Request, resp: Response, <how to do?>): ...
 Headers = Dict[str, str]
-HeaderList = Union[Headers, List[Tuple[str, str]]]
-ResponseStatus = Union[http.HTTPStatus, str, int]
-StoreArgument = Optional[Dict[str, Any]]
-Resource = object
-RangeSetHeader = Union[Tuple[int, int, int], Tuple[int, int, int, str]]
-
-
-class ResponderMethod(Protocol):
-    def __call__(
-        self,
-        resource: Resource,
-        req: Request,
-        resp: Response,
-        **kwargs: Any,
-    ) -> None: ...
+"""Headers dictionary returned by the framework."""
 
 
 # WSGI
 class ReadableIO(Protocol):
-    def read(self, n: Optional[int] = ..., /) -> bytes: ...
+    """File like protocol that defines only a read method."""
 
-
-ProcessRequestMethod = Callable[['Request', 'Response'], None]
-ProcessResourceMethod = Callable[
-    ['Request', 'Response', Resource, Dict[str, Any]], None
-]
-ProcessResponseMethod = Callable[['Request', 'Response', Resource, bool], None]
-
-
-class ResponderCallable(Protocol):
-    def __call__(self, req: Request, resp: Response, **kwargs: Any) -> None: ...
+    def read(self, n: Optional[int] = ..., /) -> bytes: ...
 
 
 # ASGI
 class AsyncReadableIO(Protocol):
+    """Async file-like protocol that defines only a read method, and is iterable."""
+
     async def read(self, n: Optional[int] = ..., /) -> bytes: ...
     def __aiter__(self) -> AsyncIterator[bytes]: ...
 
 
-class AsgiResponderMethod(Protocol):
-    async def __call__(
-        self,
-        resource: Resource,
-        req: AsgiRequest,
-        resp: AsgiResponse,
-        **kwargs: Any,
-    ) -> None: ...
-
-
-AsgiReceive = Callable[[], Awaitable['AsgiEvent']]
-AsgiSend = Callable[['AsgiSendMsg'], Awaitable[None]]
-AsgiProcessRequestMethod = Callable[['AsgiRequest', 'AsgiResponse'], Awaitable[None]]
-AsgiProcessResourceMethod = Callable[
-    ['AsgiRequest', 'AsgiResponse', Resource, Dict[str, Any]], Awaitable[None]
-]
-AsgiProcessResponseMethod = Callable[
-    ['AsgiRequest', 'AsgiResponse', Resource, bool], Awaitable[None]
-]
-AsgiProcessRequestWsMethod = Callable[['AsgiRequest', 'WebSocket'], Awaitable[None]]
-AsgiProcessResourceWsMethod = Callable[
-    ['AsgiRequest', 'WebSocket', Resource, Dict[str, Any]], Awaitable[None]
-]
-SseEmitter = AsyncIterator[Optional['SSEvent']]
-ResponseCallbacks = Union[
-    Tuple[Callable[[], None], Literal[False]],
-    Tuple[Callable[[], Awaitable[None]], Literal[True]],
-]
-
-
-class AsgiResponderCallable(Protocol):
-    async def __call__(
-        self, req: AsgiRequest, resp: AsgiResponse, **kwargs: Any
-    ) -> None: ...
-
-
-class AsgiResponderWsCallable(Protocol):
-    async def __call__(
-        self, req: AsgiRequest, ws: WebSocket, **kwargs: Any
-    ) -> None: ...
-
-
-# Routing
-
-MethodDict = Union[
-    Dict[str, ResponderCallable],
-    Dict[str, Union[AsgiResponderCallable, AsgiResponderWsCallable]],
-]
-
-
-class FindMethod(Protocol):
-    def __call__(
-        self, uri: str, req: Optional[Request]
-    ) -> Optional[Tuple[object, MethodDict, Dict[str, Any], Optional[str]]]: ...
-
-
-# Media
-class SerializeSync(Protocol):
-    def __call__(self, media: Any, content_type: Optional[str] = ...) -> bytes: ...
-
-
-DeserializeSync = Callable[[bytes], Any]
-
-Responder = Union[ResponderMethod, AsgiResponderMethod]
+SSEEmitter = AsyncIterator[Optional['SSEvent']]
+"""Async generator or iterator over Server-Sent Events
+(instances of :class:`falcon.asgi.SSEvent`).
+"""
diff --git a/falcon/util/deprecation.py b/falcon/util/deprecation.py
index 56ccf213..fdb99e72 100644
--- a/falcon/util/deprecation.py
+++ b/falcon/util/deprecation.py
@@ -17,6 +17,8 @@
 This module provides decorators to mark functions and classes as deprecated.
 """
 
+from __future__ import annotations
+
 import functools
 from typing import Any, Callable, Optional
 import warnings
diff --git a/falcon/util/mediatypes.py b/falcon/util/mediatypes.py
index eebed044..1d5218d5 100644
--- a/falcon/util/mediatypes.py
+++ b/falcon/util/mediatypes.py
@@ -16,9 +16,14 @@
 
 from __future__ import annotations
 
-from typing import Dict, Iterator, Tuple
+import dataclasses
+import functools
+import math
+from typing import Dict, Iterable, Iterator, Tuple
 
-__all__ = ('parse_header',)
+from falcon import errors
+
+__all__ = ('best_match', 'parse_header', 'quality')
 
 
 def _parse_param_old_stdlib(s: str) -> Iterator[str]:
@@ -88,3 +93,231 @@ def parse_header(line: str) -> Tuple[str, Dict[str, str]]:
         return (key.strip(), pdict)
 
     return _parse_header_old_stdlib(line)
+
+
+def _parse_media_type_header(media_type: str) -> Tuple[str, str, dict]:
+    full_type, params = parse_header(media_type)
+
+    # TODO(vytas): Workaround from python-mimeparse by J. Gregorio et al.
+    #   Do we still need this in 2024?
+    # Java URLConnection class sends an Accept header that includes a
+    #   single '*'. Turn it into a legal wildcard.
+    if full_type == '*':
+        full_type = '*/*'
+
+    main_type, separator, subtype = full_type.partition('/')
+    if not separator:
+        raise errors.InvalidMediaType('The media type value must contain type/subtype.')
+
+    return (main_type.strip(), subtype.strip(), params)
+
+
+# TODO(vytas): Should we make these data structures public?
+
+
+# PERF(vytas): It would be nice to use frozen=True as we never modify the data,
+#   but it seems to incur a performance hit (~2-3x) on CPython 3.12.
+@dataclasses.dataclass
+class _MediaType:
+    main_type: str
+    subtype: str
+    params: dict
+
+    # NOTE(vytas): Using __slots__ with dataclasses is tricky, but it seems to
+    #   work here since we are not using any default values in the definition.
+    __slots__ = ('main_type', 'subtype', 'params')
+
+    @classmethod
+    def parse(cls, media_type: str) -> _MediaType:
+        return cls(*_parse_media_type_header(media_type))
+
+
+@dataclasses.dataclass
+class _MediaRange:
+    main_type: str
+    subtype: str
+    quality: float
+    params: dict
+
+    __slots__ = ('main_type', 'subtype', 'quality', 'params')
+
+    _NOT_MATCHING = (-1, -1, -1, -1, 0.0)
+
+    _Q_VALUE_ERROR_MESSAGE = (
+        'If provided, the q parameter must be a real number '
+        'in the range 0 through 1.'
+    )
+
+    @classmethod
+    def parse(cls, media_range: str) -> _MediaRange:
+        try:
+            main_type, subtype, params = _parse_media_type_header(media_range)
+        except errors.InvalidMediaType as ex:
+            raise errors.InvalidMediaRange(
+                'The media range value must contain type/subtype.'
+            ) from ex
+
+        # NOTE(vytas): We don't need to special-case Q since the above
+        #   parse_header always lowercases parameter names.
+
+        # PERF(vytas): Short-circuit if q is absent.
+        if 'q' not in params:
+            return cls(main_type, subtype, 1.0, params)
+
+        try:
+            q = float(params.pop('q'))
+        except (TypeError, ValueError) as ex:
+            # NOTE(vytas): RFC 9110, Section 12.4.2:
+            #   weight = OWS ";" OWS "q=" qvalue
+            #   qvalue = ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3("0") ] )
+            raise errors.InvalidMediaRange(cls._Q_VALUE_ERROR_MESSAGE) from ex
+
+        if not (0.0 <= q <= 1.0) or not math.isfinite(q):
+            raise errors.InvalidMediaRange(cls._Q_VALUE_ERROR_MESSAGE)
+
+        # NOTE(vytas): RFC 9110, Section 12.4.2 states that a sender of qvalue
+        #   MUST NOT generate more than three digits after the decimal point,
+        #   but we are more permissive here, and opt not to spend any extra CPU
+        #   cycles, if we have already managed to convert the value to float.
+
+        return cls(main_type, subtype, q, params)
+
+    def match_score(self, media_type: _MediaType) -> Tuple[int, int, int, int, float]:
+        if self.main_type == '*' or media_type.main_type == '*':
+            main_matches = 0
+        elif self.main_type != media_type.main_type:
+            return self._NOT_MATCHING
+        else:
+            main_matches = 1
+
+        if self.subtype == '*' or media_type.subtype == '*':
+            sub_matches = 0
+        elif self.subtype != media_type.subtype:
+            return self._NOT_MATCHING
+        else:
+            sub_matches = 1
+
+        # PERF(vytas): We could also use bitwise operators directly between
+        #   params.keys(), but set()/frozenset() seem to outperform dict.keys()
+        #   slightly regardless of the number of elements, especially when we
+        #   reuse the same sets for both intersection and symmetric_difference.
+        mr_pnames = frozenset(self.params)
+        mt_pnames = frozenset(media_type.params)
+
+        exact_match = 0 if mr_pnames ^ mt_pnames else 1
+
+        matching = mr_pnames & mt_pnames
+        for pname in matching:
+            if self.params[pname] != media_type.params[pname]:
+                return self._NOT_MATCHING
+
+        return (main_matches, sub_matches, exact_match, len(matching), self.quality)
+
+
+# PERF(vytas): It is possible to cache a classmethod too, but the invocation is
+#   less efficient, especially in the case of a cache hit.
+# NOTE(vytas): Also, if we decide to make these classes public, we need to keep
+#   these cached parsers private.
+_parse_media_type = functools.lru_cache(_MediaType.parse)
+_parse_media_range = functools.lru_cache(_MediaRange.parse)
+
+
+@functools.lru_cache()
+def _parse_media_ranges(header: str) -> Tuple[_MediaRange, ...]:
+    return tuple(_MediaRange.parse(media_range) for media_range in header.split(','))
+
+
+@functools.lru_cache()
+def quality(media_type: str, header: str) -> float:
+    """Get quality of the most specific matching media range.
+
+    Media-ranges are parsed from the provided `header` value according to
+    RFC 9110, Section 12.5.1 (the ``Accept`` header).
+
+    The provided `media_type` is matched against each of the parsed media
+    ranges, and the fitness of each match is assessed as follows
+    (in the decreasing priority list of criteria):
+
+    1. Do the main types (as in ``type/subtype``) match?
+
+       The types must either match exactly, or as wildcard (``*``).
+       The matches involving a wildcard are prioritized lower.
+
+    2. Do the subtypes (as in ``type/subtype``) match?
+
+       The subtypes must either match exactly, or as wildcard (``*``).
+       The matches involving a wildcard are prioritized lower.
+
+    3. Do the parameters match exactly?
+
+       If all the parameter names and values (if any) between the media range
+       and media type match exactly, such match is prioritized higher than
+       matches involving extraneous parameters on either side.
+
+       Note that if parameter names match, the corresponding values must also
+       be equal, or the provided media type is considered not to match the
+       media range in question at all.
+
+    4. The number of matching parameters.
+
+    5. Finally, if two or more best media range matches are equally fit
+       according to all of the above criteria (1) through (4), the highest
+       quality (i.e., the value of the ``q`` parameter) of these is returned.
+
+    Note:
+        With the exception of evaluating the exact parameter match (3), the
+        number of extraneous parameters (i.e. where the names are only present
+        in the media type, or only in the media range) currently does not
+        influence the described specificity sort order.
+
+    Args:
+        media_type: The Internet media type to match against the provided
+            HTTP ``Accept`` header value.
+        header: The value of a header that conforms to the format of the
+            HTTP ``Accept`` header.
+
+    Returns:
+        Quality of the most specific media range matching the provided
+        `media_type`. (If none matches, 0.0 is returned.)
+    """
+    parsed_media_type = _parse_media_type(media_type)
+    most_specific = max(
+        media_range.match_score(parsed_media_type)
+        for media_range in _parse_media_ranges(header)
+    )
+    return most_specific[-1]
+
+
+def best_match(media_types: Iterable[str], header: str) -> str:
+    """Choose media type with the highest :func:`quality` from a list of candidates.
+
+    Args:
+        media_types: An iterable over one or more Internet media types
+            to match against the provided header value.
+        header: The value of a header that conforms to the format of the
+            HTTP ``Accept`` header.
+
+    Returns:
+        Best match from the supported candidates, or an empty string if the
+        provided header value does not match any of the given types.
+    """
+    # PERF(vytas): Using the default parameter, i.e., max(..., default='', 0.0)
+    #   would be much nicer than EAFP, but for some reason it is quite slow
+    #   regardless of whether media_types is empty or not.
+    try:
+        matching, best_quality = max(
+            ((media_type, quality(media_type, header)) for media_type in media_types),
+            key=lambda mt_quality: mt_quality[1],
+        )
+        if best_quality > 0.0:
+            return matching
+    except errors.InvalidMediaType:
+        # NOTE(vytas): Do not swallow instances of InvalidMediaType
+        #   (it a subclass of ValueError).
+        raise
+    except ValueError:
+        # NOTE(vytas): Barring unknown bugs, we only expect unhandled
+        #   ValueErrors from supplying an empty media_types value.
+        pass
+
+    return ''
diff --git a/falcon/util/misc.py b/falcon/util/misc.py
index 051d6269..1f68626c 100644
--- a/falcon/util/misc.py
+++ b/falcon/util/misc.py
@@ -30,7 +30,7 @@ import functools
 import http
 import inspect
 import re
-from typing import Any, Callable, Dict, List, Optional, Tuple, Union
+from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Union
 import unicodedata
 
 from falcon import status_codes
@@ -215,7 +215,9 @@ def http_date_to_dt(http_date: str, obs_date: bool = False) -> datetime.datetime
 
 
 def to_query_str(
-    params: Dict[str, Any], comma_delimited_lists: bool = True, prefix: bool = True
+    params: Optional[Mapping[str, Any]],
+    comma_delimited_lists: bool = True,
+    prefix: bool = True,
 ) -> str:
     """Convert a dictionary of parameters to a query string.
 
@@ -330,7 +332,7 @@ def get_argnames(func: Callable[..., Any]) -> List[str]:
     return args
 
 
-def secure_filename(filename: Optional[str]) -> str:
+def secure_filename(filename: str) -> str:
     """Sanitize the provided `filename` to contain only ASCII characters.
 
     Only ASCII alphanumerals, ``'.'``, ``'-'`` and ``'_'`` are allowed for
diff --git a/falcon/util/reader.py b/falcon/util/reader.py
index 645b3552..8f5431d5 100644
--- a/falcon/util/reader.py
+++ b/falcon/util/reader.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2020 by Vytautas Liuolia.
+# Copyright 2019-2024 by Vytautas Liuolia.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
diff --git a/falcon/util/structures.py b/falcon/util/structures.py
index 020d4347..3a9716f5 100644
--- a/falcon/util/structures.py
+++ b/falcon/util/structures.py
@@ -259,13 +259,10 @@ class ETag(str):
             resp.status = falcon.HTTP_200
 
     (See also: RFC 7232)
-
-    Attributes:
-        is_weak (bool): ``True`` if the entity-tag is weak, otherwise ``False``.
-
     """
 
-    is_weak = False
+    is_weak: bool = False
+    """``True`` if the entity-tag is weak, otherwise ``False``."""
 
     def strong_compare(self, other: ETag) -> bool:
         """Perform a strong entity-tag comparison.
diff --git a/falcon/util/sync.py b/falcon/util/sync.py
index 6d9d4e45..f7109d5f 100644
--- a/falcon/util/sync.py
+++ b/falcon/util/sync.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import asyncio
 from concurrent.futures import ThreadPoolExecutor
 from functools import partial
diff --git a/falcon/util/time.py b/falcon/util/time.py
index 952cbb3b..741ad8fe 100644
--- a/falcon/util/time.py
+++ b/falcon/util/time.py
@@ -9,6 +9,8 @@ for convenience::
     tz = falcon.TimezoneGMT()
 """
 
+from __future__ import annotations
+
 import datetime
 from typing import Optional
 
diff --git a/falcon/util/uri.py b/falcon/util/uri.py
index 80e6b62b..549dc721 100644
--- a/falcon/util/uri.py
+++ b/falcon/util/uri.py
@@ -23,6 +23,8 @@ in the `falcon` module, and so must be explicitly imported::
     name, port = uri.parse_host('example.org:8080')
 """
 
+from __future__ import annotations
+
 from typing import Callable, Dict, List, Optional, overload, Tuple, Union
 
 from falcon.constants import PYPY
diff --git a/falcon/vendor/__init__.py b/falcon/vendor/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/falcon/vendor/mimeparse/LICENSE b/falcon/vendor/mimeparse/LICENSE
deleted file mode 100755
index 89de3547..00000000
--- a/falcon/vendor/mimeparse/LICENSE
+++ /dev/null
@@ -1,17 +0,0 @@
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
diff --git a/falcon/vendor/mimeparse/__init__.py b/falcon/vendor/mimeparse/__init__.py
deleted file mode 100644
index 223767a7..00000000
--- a/falcon/vendor/mimeparse/__init__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-"""
-This module wraps code from the MIT-licensed python-mimeparse project.
-
-The original project source code may be obtained from GitHub by visiting the
-following URL:
-
-    https://github.com/dbtsai/python-mimeparse
-
-"""
-
-from .mimeparse import *  # NOQA
diff --git a/falcon/vendor/mimeparse/mimeparse.py b/falcon/vendor/mimeparse/mimeparse.py
deleted file mode 100755
index f96e6338..00000000
--- a/falcon/vendor/mimeparse/mimeparse.py
+++ /dev/null
@@ -1,191 +0,0 @@
-from falcon.util.mediatypes import parse_header
-
-__version__ = '1.6.0'
-__author__ = 'Joe Gregorio'
-__email__ = 'joe@bitworking.org'
-__license__ = 'MIT License'
-__credits__ = ''
-
-
-class MimeTypeParseException(ValueError):
-    pass
-
-
-def parse_mime_type(mime_type):
-    """Parses a mime-type into its component parts.
-
-    Carves up a mime-type and returns a tuple of the (type, subtype, params)
-    where 'params' is a dictionary of all the parameters for the media range.
-    For example, the media range 'application/xhtml;q=0.5' would get parsed
-    into:
-
-       ('application', 'xhtml', {'q', '0.5'})
-
-    :rtype: (str,str,dict)
-    """
-    full_type, params = parse_header(mime_type)
-    # Java URLConnection class sends an Accept header that includes a
-    # single '*'. Turn it into a legal wildcard.
-    if full_type == '*':
-        full_type = '*/*'
-
-    type_parts = full_type.split('/') if '/' in full_type else None
-    if not type_parts or len(type_parts) > 2:
-        raise MimeTypeParseException(
-            "Can't parse type \"{}\"".format(full_type))
-
-    (type, subtype) = type_parts
-
-    return (type.strip(), subtype.strip(), params)
-
-
-def parse_media_range(range):
-    """Parse a media-range into its component parts.
-
-    Carves up a media range and returns a tuple of the (type, subtype,
-    params) where 'params' is a dictionary of all the parameters for the media
-    range.  For example, the media range 'application/*;q=0.5' would get parsed
-    into:
-
-       ('application', '*', {'q', '0.5'})
-
-    In addition this function also guarantees that there is a value for 'q'
-    in the params dictionary, filling it in with a proper default if
-    necessary.
-
-    :rtype: (str,str,dict)
-    """
-    (type, subtype, params) = parse_mime_type(range)
-    params.setdefault('q', params.pop('Q', None))  # q is case insensitive
-    try:
-        if not params['q'] or not 0 <= float(params['q']) <= 1:
-            params['q'] = '1'
-    except ValueError:  # from float()
-        params['q'] = '1'
-
-    return (type, subtype, params)
-
-
-def quality_and_fitness_parsed(mime_type, parsed_ranges):
-    """Find the best match for a mime-type amongst parsed media-ranges.
-
-    Find the best match for a given mime-type against a list of media_ranges
-    that have already been parsed by parse_media_range(). Returns a tuple of
-    the fitness value and the value of the 'q' quality parameter of the best
-    match, or (-1, 0) if no match was found. Just as for quality_parsed(),
-    'parsed_ranges' must be a list of parsed media ranges.
-
-    :rtype: (float,int)
-    """
-    best_fitness = -1
-    best_fit_q = 0
-    (target_type, target_subtype, target_params) = \
-        parse_media_range(mime_type)
-
-    for (type, subtype, params) in parsed_ranges:
-
-        # check if the type and the subtype match
-        type_match = (
-            type in (target_type, '*') or
-            target_type == '*'
-        )
-        subtype_match = (
-            subtype in (target_subtype, '*') or
-            target_subtype == '*'
-        )
-
-        # if they do, assess the "fitness" of this mime_type
-        if type_match and subtype_match:
-
-            # 100 points if the type matches w/o a wildcard
-            fitness = type == target_type and 100 or 0
-
-            # 10 points if the subtype matches w/o a wildcard
-            fitness += subtype == target_subtype and 10 or 0
-
-            # 1 bonus point for each matching param besides "q"
-            param_matches = sum([
-                1 for (key, value) in target_params.items()
-                if key != 'q' and key in params and value == params[key]
-            ])
-            fitness += param_matches
-
-            # finally, add the target's "q" param (between 0 and 1)
-            fitness += float(target_params.get('q', 1))
-
-            if fitness > best_fitness:
-                best_fitness = fitness
-                best_fit_q = params['q']
-
-    return float(best_fit_q), best_fitness
-
-
-def quality_parsed(mime_type, parsed_ranges):
-    """Find the best match for a mime-type amongst parsed media-ranges.
-
-    Find the best match for a given mime-type against a list of media_ranges
-    that have already been parsed by parse_media_range(). Returns the 'q'
-    quality parameter of the best match, 0 if no match was found. This function
-    behaves the same as quality() except that 'parsed_ranges' must be a list of
-    parsed media ranges.
-
-    :rtype: float
-    """
-
-    return quality_and_fitness_parsed(mime_type, parsed_ranges)[0]
-
-
-def quality(mime_type, ranges):
-    """Return the quality ('q') of a mime-type against a list of media-ranges.
-
-    Returns the quality 'q' of a mime-type when compared against the
-    media-ranges in ranges. For example:
-
-    >>> quality('text/html','text/*;q=0.3, text/html;q=0.7,
-                  text/html;level=1, text/html;level=2;q=0.4, */*;q=0.5')
-    0.7
-
-    :rtype: float
-    """
-    parsed_ranges = [parse_media_range(r) for r in ranges.split(',')]
-
-    return quality_parsed(mime_type, parsed_ranges)
-
-
-def best_match(supported, header):
-    """Return mime-type with the highest quality ('q') from list of candidates.
-
-    Takes a list of supported mime-types and finds the best match for all the
-    media-ranges listed in header. The value of header must be a string that
-    conforms to the format of the HTTP Accept: header. The value of 'supported'
-    is a list of mime-types. The list of supported mime-types should be sorted
-    in order of increasing desirability, in case of a situation where there is
-    a tie.
-
-    >>> best_match(['application/xbel+xml', 'text/xml'],
-                   'text/*;q=0.5,*/*; q=0.1')
-    'text/xml'
-
-    :rtype: str
-    """
-    split_header = _filter_blank(header.split(','))
-    parsed_header = [parse_media_range(r) for r in split_header]
-    weighted_matches = []
-    pos = 0
-    for mime_type in supported:
-        weighted_matches.append((
-            quality_and_fitness_parsed(mime_type, parsed_header),
-            pos,
-            mime_type
-        ))
-        pos += 1
-    weighted_matches.sort()
-
-    return weighted_matches[-1][0][0] and weighted_matches[-1][2] or ''
-
-
-def _filter_blank(i):
-    """Return all non-empty items in the list."""
-    for s in i:
-        if s.strip():
-            yield s
diff --git a/falcon/version.py b/falcon/version.py
index 35f56858..704661dc 100644
--- a/falcon/version.py
+++ b/falcon/version.py
@@ -14,5 +14,5 @@
 
 """Falcon version."""
 
-__version__ = '4.0.0.dev2'
+__version__ = '4.0.0a2'
 """Current version of Falcon."""
diff --git a/pyproject.toml b/pyproject.toml
index ee74ee31..4fe30640 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -84,7 +84,6 @@ exclude = ["examples", "tests"]
     exclude = [
         "falcon/bench",
         "falcon/cmd",
-        "falcon/vendor",
     ]
     disallow_untyped_defs = true
     warn_unused_ignores = true
@@ -114,14 +113,6 @@ exclude = ["examples", "tests"]
         ]
         ignore_missing_imports = true
 
-    [[tool.mypy.overrides]]
-        module = [
-            "falcon.media.validators.*",
-            "falcon.testing.*",
-            "falcon.vendor.*",
-        ]
-        disallow_untyped_defs = false
-
 [tool.towncrier]
     package = "falcon"
     package_dir = ""
@@ -161,7 +152,6 @@ exclude = ["examples", "tests"]
     target-version = ["py38"]
     skip-string-normalization = true
     line-length = 88
-    extend-exclude = "falcon/vendor"
 
 [tool.blue]
     # NOTE(vytas): Before switching to Ruff, Falcon used the Blue formatter.
@@ -169,13 +159,11 @@ exclude = ["examples", "tests"]
     #   only minor cosmetic changes in a handful of files.
     target-version = ["py38"]
     line-length = 88
-    extend-exclude = "falcon/vendor"
 
 [tool.ruff]
     target-version = "py38"
     format.quote-style = "single"
     line-length = 88
-    extend-exclude = ["falcon/vendor"]
     builtins = [
         "ignore",
         "attr",
@@ -190,7 +178,6 @@ exclude = ["examples", "tests"]
         "build",
         "dist",
         "docs",
-        "examples",
         "falcon/bench/nuts",
     ]
 
@@ -200,7 +187,8 @@ exclude = ["examples", "tests"]
         "E",
         "F",
         "W",
-        "I"
+        "I",
+        "FA"
     ]
 
 [tool.ruff.lint.mccabe]
@@ -213,6 +201,7 @@ exclude = ["examples", "tests"]
         "F403"
     ]
     "falcon/uri.py" = ["F401"]
+    "examples/*" = ["FA"]
 
 [tool.ruff.lint.isort]
     case-sensitive = false
diff --git a/setup.py b/setup.py
index 9751f825..3e5ce1e9 100644
--- a/setup.py
+++ b/setup.py
@@ -41,7 +41,6 @@ if HAS_CYTHON and IS_CPYTHON and not DISABLE_EXTENSION:
         'falcon.media',
         'falcon.routing',
         'falcon.util',
-        'falcon.vendor.mimeparse',
     ]
 
     modules_to_exclude = [
@@ -59,6 +58,7 @@ if HAS_CYTHON and IS_CPYTHON and not DISABLE_EXTENSION:
         # NOTE(vytas): It is pointless to cythonize reader.py, since cythonized
         #   Falcon is using reader.pyx instead.
         'falcon.hooks',
+        'falcon.inspect',
         'falcon.responders',
         'falcon.util.reader',
         'falcon.util.sync',
diff --git a/tools/testing/install_hug.sh b/tools/testing/install_hug.sh
deleted file mode 100755
index 67e1ab8e..00000000
--- a/tools/testing/install_hug.sh
+++ /dev/null
@@ -1,23 +0,0 @@
-#!/usr/bin/env bash
-
-HUG_PATH=.ecosystem/hug
-
-# Clean up in case we are running locally and not in CI
-rm -rf $HUG_PATH
-
-mkdir -p .ecosystem
-git clone https://github.com/timothycrosley/hug.git $HUG_PATH
-virtualenv $HUG_PATH/.venv
-source $HUG_PATH/.venv/bin/activate
-
-pip install hug
-
-pushd $HUG_PATH
-git checkout master
-git pull
-HUG_VERSION=$(pip freeze | grep hug | cut -c 6-)
-git checkout tags/$HUG_VERSION
-pip install -rrequirements/build.txt
-popd
-
-pip install .  # Override Hug's Falcon version with the one under test
diff --git a/tools/testing/test_hug.sh b/tools/testing/test_hug.sh
deleted file mode 100755
index 85d656e5..00000000
--- a/tools/testing/test_hug.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/env bash
-
-set -e
-
-pushd .ecosystem/hug
-source .venv/bin/activate
-python -m pytest tests
-popd
diff --git a/tox.ini b/tox.ini
index 0b96f9d7..767a018c 100644
--- a/tox.ini
+++ b/tox.ini
@@ -15,6 +15,7 @@
 envlist = cleanup,
           ruff,
           pep8,
+          pep8-docstrings,
           mypy,
           mypy_tests,
           mintest,
@@ -48,29 +49,6 @@ commands = coverage combine
            coverage html -d {toxinidir}/.coverage_html
            coverage report --fail-under 100
 
-# --------------------------------------------------------------------
-# ASGI servers
-# --------------------------------------------------------------------
-
-[testenv:daphne]
-deps = {[testenv]deps}
-       daphne
-
-[testenv:hypercorn]
-# TODO(vytas): Unpin Hypercorn once the buggy versions are yanked upstream.
-deps = {[testenv]deps}
-       hypercorn != 0.14.0, != 0.14.1
-
-# --------------------------------------------------------------------
-# Test without optional packages
-# --------------------------------------------------------------------
-
-[testenv:no_optional_packages]
-deps = {[testenv]deps}
-commands = python "{toxinidir}/tools/clean.py" "{toxinidir}/falcon"
-           pip uninstall --yes python-rapidjson daphne orjson
-           pytest tests []
-
 # --------------------------------------------------------------------
 # Coverage
 # --------------------------------------------------------------------
@@ -93,11 +71,6 @@ deps = {[testenv]deps}
        jsonschema
 commands = coverage run -m pytest tests []
 
-[testenv:pytest_sans_msgpack]
-deps = {[testenv]deps}
-commands = pip uninstall --yes msgpack
-           coverage run -m pytest tests -k "test_ws and test_msgpack_missing"
-
 [testenv:py312]
 basepython = python3.12
 deps = {[testenv]deps}
@@ -105,12 +78,6 @@ deps = {[testenv]deps}
        jsonschema
 commands = {[with-coverage]commands}
 
-[testenv:py312_sans_msgpack]
-basepython = python3.12
-deps = {[testenv]deps}
-commands = pip uninstall --yes msgpack
-           coverage run -m pytest tests -k "test_ws and test_msgpack_missing"
-
 [testenv:py312_nocover]
 basepython = python3.12
 deps = {[testenv]deps}
@@ -134,13 +101,13 @@ deps = -r{toxinidir}/requirements/tests
        pdbpp
 
 [testenv:py3_debug]
-basepython = python3.10
+basepython = python3.12
 deps = {[with-debug-tools]deps}
        uvicorn
        jsonschema
 
 # --------------------------------------------------------------------
-# mypy
+# Mypy
 # --------------------------------------------------------------------
 
 [testenv:mypy]
@@ -229,12 +196,33 @@ commands = {[with-cython]commands}
 setenv = {[with-cython]setenv}
 deps = {[with-cython]deps}
        gunicorn
-       # NOTE(caselit): meinheld requires greenlet<0.5 that's not compatible with py3.10
-       meinheld
        uwsgi
        waitress
 commands = pytest -v tests/test_wsgi_servers.py
 
+[testenv:wsgi_meinheld]
+# NOTE(caselit): meinheld requires greenlet<0.5 that's not compatible with py3.10
+basepython = python3.8
+setenv = {[with-cython]setenv}
+deps = {[with-cython]deps}
+       gunicorn
+       meinheld
+commands = pytest -v tests/test_wsgi_servers.py
+
+# --------------------------------------------------------------------
+# ASGI servers
+# --------------------------------------------------------------------
+
+[testenv:daphne]
+deps = {[testenv]deps}
+       daphne
+commands = pytest -v tests/asgi/test_asgi_servers.py
+
+[testenv:hypercorn]
+deps = {[testenv]deps}
+       hypercorn
+commands = pytest -v tests/asgi/test_asgi_servers.py
+
 # --------------------------------------------------------------------
 # Smoke testing with a sample app
 # --------------------------------------------------------------------
@@ -266,8 +254,17 @@ commands = {[smoke-test]commands}
 
 [testenv:pep8]
 deps = ruff
+skip_install = True
 commands = ruff check []
 
+[testenv:pep8-docstrings]
+deps = ruff
+skip_install = True
+commands = ruff check \
+             --exclude=.ecosystem,.eggs,.git,.tox,.venv,build,dist,docs,examples,tests,falcon/bench/nuts \
+             --select=D205,D212,D400,D401,D403,D404 \
+             []
+
 [testenv:ruff]
 deps = ruff>=0.3.7
 skip_install = True
@@ -276,35 +273,23 @@ commands = ruff format --check . []
 [testenv:reformat]
 deps = ruff>=0.3.7
 skip_install = True
-commands = ruff format . []
-
-[testenv:pep8-docstrings]
-deps = ruff
-commands = ruff check \
-             --exclude=.ecosystem,.eggs,.git,.tox,.venv,build,dist,docs,examples,tests,falcon/vendor,falcon/bench/nuts \
-             --select=D205,D212,D400,D401,D403,D404 \
-             []
-
-[testenv:pep8-examples]
-deps = ruff
-commands = ruff check examples []
+commands =
+    ruff format . []
+    ruff check --fix
 
 # --------------------------------------------------------------------
 # For viewing environ dicts generated by various WSGI servers
 # --------------------------------------------------------------------
 
-[testenv:py310_dump_gunicorn]
-basepython = python3.10
+[testenv:dump_gunicorn]
 deps = gunicorn
 commands = gunicorn -b localhost:8000 tests.dump_wsgi
 
-[testenv:py310_dump_waitress]
-basepython = python3.10
+[testenv:dump_waitress]
 deps = waitress
 commands = waitress-serve --listen=localhost:8000 tests.dump_wsgi:application
 
-[testenv:py310_dump_wsgiref]
-basepython = python3.10
+[testenv:dump_wsgiref]
 commands = python tests/dump_wsgi.py
 
 # --------------------------------------------------------------------
@@ -343,28 +328,16 @@ basepython = pypy3
 deps = -r{toxinidir}/requirements/bench
 commands = falcon-bench []
 
-# --------------------------------------------------------------------
-# Check for new versions of vendored packages
-# --------------------------------------------------------------------
-
-[testenv:check_vendored]
-basepython = python3.10
-allowlist_externals = {toxinidir}/tools/check-vendored.sh
-deps =
-commands = {toxinidir}/tools/check-vendored.sh
-
 # --------------------------------------------------------------------
 # Package sanity check with twine
 # --------------------------------------------------------------------
 
 [testenv:twine_check]
-basepython = python3.10
 skipsdist = True
-deps = setuptools
+deps = build
        twine
-       wheel
 commands =
-    python {toxinidir}/setup.py bdist_wheel sdist
+    python -m build
     twine check {toxinidir}/dist/*
 
 # --------------------------------------------------------------------
@@ -414,7 +387,11 @@ commands =
     {toxinidir}/tools/generate_dash.sh
 
 # --------------------------------------------------------------------
-# Tutorial ("look") tests
+# Tutorial tests
+#
+#   - Tutorial ("look") tests
+#   - ASGI tutorial ("asgilook") tests
+#   - WebSockets tutorial ("ws_tutorial") tests
 # --------------------------------------------------------------------
 
 [testenv:look]
@@ -423,10 +400,6 @@ deps =
 commands =
     pytest {toxinidir}/examples/look/tests
 
-# --------------------------------------------------------------------
-# ASGI tutorial ("asgilook") tests
-# --------------------------------------------------------------------
-
 [testenv:asgilook]
 basepython = python3.12
 deps =
@@ -440,10 +413,6 @@ commands =
         --cov-report term-missing \
         {toxinidir}/examples/asgilook/tests/
 
-# --------------------------------------------------------------------
-# WebSockets tutorial ("ws_tutorial") tests
-# --------------------------------------------------------------------
-
 [testenv:ws_tutorial]
 basepython = python3.12
 deps =
@@ -457,17 +426,6 @@ commands =
         --cov-report term-missing \
         {toxinidir}/examples/ws_tutorial/tests/
 
-# --------------------------------------------------------------------
-# Ecosystem
-# --------------------------------------------------------------------
-
-[testenv:hug]
-basepython = python3.8
-deps = virtualenv
-commands =
-     {toxinidir}/tools/testing/install_hug.sh
-     {toxinidir}/tools/testing/test_hug.sh
-
 # --------------------------------------------------------------------
 # E2E tests
 # --------------------------------------------------------------------
@@ -485,4 +443,3 @@ deps =
     -r{toxinidir}/requirements/e2e
 commands =
     pytest {toxinidir}/e2e-tests/ --browser=firefox
-

test_patch: diff --git a/tests/asgi/_cythonized.pyx b/tests/asgi/_cythonized.pyx
index 091e0735..c08603cf 100644
--- a/tests/asgi/_cythonized.pyx
+++ b/tests/asgi/_cythonized.pyx
@@ -8,22 +8,22 @@ from falcon.media.validators.jsonschema import validate
 
 
 _MESSAGE_SCHEMA = {
-	'definitions': {},
-	'$schema': 'http://json-schema.org/draft-07/schema#',
-	'$id': 'http://example.com/root.json',
-	'type': 'object',
-	'title': 'The Root Schema',
-	'required': ['message'],
-	'properties': {
-		'message': {
-			'$id': '#/properties/message',
-			'type': 'string',
-			'title': 'The Message Schema',
-			'default': '',
-			'examples': ['hello world'],
-			'pattern': '^(.*)$'
-		}
-	}
+    'definitions': {},
+    '$schema': 'http://json-schema.org/draft-07/schema#',
+    '$id': 'http://example.com/root.json',
+    'type': 'object',
+    'title': 'The Root Schema',
+    'required': ['message'],
+    'properties': {
+        'message': {
+            '$id': '#/properties/message',
+            'type': 'string',
+            'title': 'The Message Schema',
+            'default': '',
+            'examples': ['hello world'],
+            'pattern': '^(.*)$'
+        }
+    }
 }
 
 
@@ -44,19 +44,9 @@ class NOPClass:
 
 
 class TestResourceWithValidation:
-    @validate(resp_schema=_MESSAGE_SCHEMA, is_async=True)
-    async def on_get(self, req, resp):
-        resp.media = {
-  			'message': 'hello world'
-		}
-
-
-class TestResourceWithValidationNoHint:
     @validate(resp_schema=_MESSAGE_SCHEMA)
     async def on_get(self, req, resp):
-        resp.media = {
-  			'message': 'hello world'
-		}
+        resp.media = {'message': 'hello world'}
 
 
 class TestResourceWithScheduledJobs:
@@ -85,7 +75,7 @@ class TestResourceWithScheduledJobsAsyncRequired:
             pass
 
         # NOTE(kgriffs): This will fail later since we can't detect
-        #	up front that it isn't a coroutine function.
+        #    up front that it isn't a coroutine function.
         resp.schedule(background_job_sync)
 
 
@@ -99,13 +89,6 @@ async def my_after_hook(req, resp, resource):
 
 
 class TestResourceWithHooks:
-    @falcon.before(my_before_hook, is_async=True)
-    @falcon.after(my_after_hook, is_async=True)
-    async def on_get(self, req, resp):
-        pass
-
-
-class TestResourceWithHooksNoHint:
     @falcon.before(my_before_hook)
     @falcon.after(my_after_hook)
     async def on_get(self, req, resp):
diff --git a/tests/asgi/test_cythonized_asgi.py b/tests/asgi/test_cythonized_asgi.py
index 67d7499b..06e1da4d 100644
--- a/tests/asgi/test_cythonized_asgi.py
+++ b/tests/asgi/test_cythonized_asgi.py
@@ -31,6 +31,7 @@ if pyximport:
 else:
     _CYTHON_FUNC_TEST_TYPES = []
 
+pytestmark = pytest.mark.skipif(not pyximport, reason='Cython not installed')
 
 # NOTE(vytas): Cython 3.0+ now correctly marks cythonized coroutines as such,
 #   however, the relevant protocol is only available in Python 3.10+.
@@ -59,7 +60,6 @@ class NOPClass:
         pass
 
 
-@pytest.mark.skipif(not pyximport, reason='Cython not installed')
 @pytest.mark.parametrize('func', _CYTHON_FUNC_TEST_TYPES)
 def test_is_cython_func(func):
     assert not is_python_func(func)
@@ -80,23 +80,20 @@ def test_not_cython_func(func):
     assert is_python_func(func)
 
 
-@pytest.mark.skipif(not pyximport, reason='Cython not installed')
 def test_jsonchema_validator(client, util):
     with util.disable_asgi_non_coroutine_wrapping():
         if CYTHON_COROUTINE_HINT:
-            client.app.add_route('/', _cythonized.TestResourceWithValidationNoHint())
+            client.app.add_route('/', _cythonized.TestResourceWithValidation())
         else:
             with pytest.raises(TypeError):
                 client.app.add_route(
-                    '/wowsuchfail', _cythonized.TestResourceWithValidationNoHint()
+                    '/wowsuchfail', _cythonized.TestResourceWithValidation()
                 )
-
-            client.app.add_route('/', _cythonized.TestResourceWithValidation())
+            return
 
     client.simulate_get()
 
 
-@pytest.mark.skipif(not pyximport, reason='Cython not installed')
 def test_scheduled_jobs(client):
     resource = _cythonized.TestResourceWithScheduledJobs()
     client.app.add_route('/', resource)
@@ -107,7 +104,6 @@ def test_scheduled_jobs(client):
     assert resource.counter['backround:on_get:sync'] == 40
 
 
-@pytest.mark.skipif(not pyximport, reason='Cython not installed')
 def test_scheduled_jobs_type_error(client):
     client.app.add_route(
         '/wowsuchfail', _cythonized.TestResourceWithScheduledJobsAsyncRequired()
@@ -123,16 +119,15 @@ def test_scheduled_jobs_type_error(client):
         client.simulate_get('/wowsuchfail')
 
 
-@pytest.mark.skipif(not pyximport, reason='Cython not installed')
 def test_hooks(client, util):
     with util.disable_asgi_non_coroutine_wrapping():
         if CYTHON_COROUTINE_HINT:
-            client.app.add_route('/', _cythonized.TestResourceWithHooksNoHint())
+            client.app.add_route('/', _cythonized.TestResourceWithHooks())
         else:
             with pytest.raises(TypeError):
-                client.app.add_route('/', _cythonized.TestResourceWithHooksNoHint())
+                client.app.add_route('/', _cythonized.TestResourceWithHooks())
 
-            client.app.add_route('/', _cythonized.TestResourceWithHooks())
+            return
 
     result = client.simulate_get()
     assert result.headers['x-answer'] == '42'
diff --git a/tests/test_after_hooks.py b/tests/test_after_hooks.py
index f6e53769..6db9b290 100644
--- a/tests/test_after_hooks.py
+++ b/tests/test_after_hooks.py
@@ -7,7 +7,7 @@ import pytest
 import falcon
 from falcon import app as wsgi
 from falcon import testing
-from falcon.typing import Resource
+from falcon._typing import Resource
 
 # --------------------------------------------------------------------
 # Fixtures
@@ -128,12 +128,12 @@ class WrappedRespondersResource:
 
 class WrappedRespondersResourceAsync:
     @falcon.after(serialize_body_async)
-    @falcon.after(validate_output, is_async=False)
+    @falcon.after(validate_output)
     async def on_get(self, req, resp):
         self.req = req
         self.resp = resp
 
-    @falcon.after(serialize_body_async, is_async=True)
+    @falcon.after(serialize_body_async)
     async def on_put(self, req, resp):
         self.req = req
         self.resp = resp
diff --git a/tests/test_before_hooks.py b/tests/test_before_hooks.py
index 40e6ba7a..174586a5 100644
--- a/tests/test_before_hooks.py
+++ b/tests/test_before_hooks.py
@@ -338,12 +338,12 @@ def test_parser_async(body, doc, util):
     with util.disable_asgi_non_coroutine_wrapping():
 
         class WrappedRespondersBodyParserAsyncResource:
-            @falcon.before(validate_param_async, 'limit', 100, is_async=True)
+            @falcon.before(validate_param_async, 'limit', 100)
             @falcon.before(parse_body_async)
             async def on_get(self, req, resp, doc=None):
                 self.doc = doc
 
-            @falcon.before(parse_body_async, is_async=False)
+            @falcon.before(parse_body_async)
             async def on_put(self, req, resp, doc=None):
                 self.doc = doc
 
diff --git a/tests/test_boundedstream.py b/tests/test_boundedstream.py
index 3c9ec209..4845fcc5 100644
--- a/tests/test_boundedstream.py
+++ b/tests/test_boundedstream.py
@@ -3,6 +3,7 @@ import io
 import pytest
 
 from falcon.stream import BoundedStream
+from falcon.util.deprecation import DeprecatedWarning
 
 
 @pytest.fixture
@@ -15,3 +16,14 @@ def test_not_writable(bounded_stream):
 
     with pytest.raises(IOError):
         bounded_stream.write(b'something something')
+
+
+def test_exhausted():
+    bs = BoundedStream(io.BytesIO(b'foobar'), 6)
+    assert not bs.eof
+    with pytest.warns(DeprecatedWarning, match='Use `eof` instead'):
+        assert not bs.is_exhausted
+    assert bs.read() == b'foobar'
+    assert bs.eof
+    with pytest.warns(DeprecatedWarning, match='Use `eof` instead'):
+        assert bs.is_exhausted
diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py
deleted file mode 100644
index 3907f268..00000000
--- a/tests/test_deprecations.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from falcon import request_helpers
-from falcon import stream
-
-
-def test_bounded_stream():
-    assert request_helpers.Body is stream.Body
-    assert request_helpers.BoundedStream is stream.BoundedStream
diff --git a/tests/test_error.py b/tests/test_error.py
index f1e2e40c..60e3e60e 100644
--- a/tests/test_error.py
+++ b/tests/test_error.py
@@ -18,7 +18,7 @@ import falcon.status_codes as status
         (falcon.HTTPGone, status.HTTP_410),
         (falcon.HTTPLengthRequired, status.HTTP_411),
         (falcon.HTTPPreconditionFailed, status.HTTP_412),
-        (falcon.HTTPPayloadTooLarge, status.HTTP_413),
+        (falcon.HTTPContentTooLarge, status.HTTP_413),
         (falcon.HTTPUriTooLong, status.HTTP_414),
         (falcon.HTTPUnsupportedMediaType, status.HTTP_415),
         (falcon.HTTPUnprocessableEntity, status.HTTP_422),
@@ -81,7 +81,7 @@ def test_with_default_title_and_desc_args(err, title, args):
         falcon.HTTPGone,
         falcon.HTTPLengthRequired,
         falcon.HTTPPreconditionFailed,
-        falcon.HTTPPayloadTooLarge,
+        falcon.HTTPContentTooLarge,
         falcon.HTTPUriTooLong,
         falcon.HTTPUnsupportedMediaType,
         falcon.HTTPUnprocessableEntity,
@@ -128,7 +128,7 @@ def test_with_title_desc_and_headers(err):
         falcon.HTTPGone,
         falcon.HTTPLengthRequired,
         falcon.HTTPPreconditionFailed,
-        falcon.HTTPPayloadTooLarge,
+        falcon.HTTPContentTooLarge,
         falcon.HTTPUriTooLong,
         falcon.HTTPUnsupportedMediaType,
         falcon.HTTPUnprocessableEntity,
@@ -195,7 +195,7 @@ def test_args_kw_only(err, args):
     [
         falcon.HTTPServiceUnavailable,
         falcon.HTTPTooManyRequests,
-        falcon.HTTPPayloadTooLarge,
+        falcon.HTTPContentTooLarge,
     ],
 )
 def test_with_retry_after(err):
@@ -210,7 +210,7 @@ def test_with_retry_after(err):
     [
         falcon.HTTPServiceUnavailable,
         falcon.HTTPTooManyRequests,
-        falcon.HTTPPayloadTooLarge,
+        falcon.HTTPContentTooLarge,
     ],
 )
 def test_with_retry_after_and_headers(err):
@@ -283,7 +283,7 @@ def test_custom_400(err, args, title, desc):
             'a, b',
             True,
         ),
-        (falcon.HTTPPayloadTooLarge, 'Retry-After', 'retry_after', 123, '123', False),
+        (falcon.HTTPContentTooLarge, 'Retry-After', 'retry_after', 123, '123', False),
         (
             falcon.HTTPRangeNotSatisfiable,
             'Content-Range',
@@ -350,3 +350,9 @@ class TestErrorsWithHeadersKW:
         assert header_name in value.headers
         assert isinstance(value.headers, dict)
         assert value.headers[header_name] == res
+
+
+def test_http_payload_too_large_deprecation():
+    with pytest.warns(match='HTTPContentTooLarge'):
+        err = errors.HTTPPayloadTooLarge()
+        assert err.title == '413 Content Too Large'
diff --git a/tests/test_error_handlers.py b/tests/test_error_handlers.py
index 5bac0842..0fad6892 100644
--- a/tests/test_error_handlers.py
+++ b/tests/test_error_handlers.py
@@ -77,6 +77,7 @@ class TestErrorHandler:
     def test_uncaught_python_error(
         self, client, get_headers, resp_content_type, resp_start
     ):
+        client.app.resp_options.xml_error_serialization = True
         result = client.simulate_get(headers=get_headers)
         assert result.status_code == 500
         assert result.headers['content-type'] == resp_content_type
diff --git a/tests/test_examples.py b/tests/test_examples.py
index 5fd5b1db..e203b823 100644
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -13,6 +13,18 @@ except ImportError:
 import falcon.testing as testing
 
 
+def test_quote(util):
+    quote = util.load_module('examples/quote.py')
+
+    resp = testing.simulate_get(quote.app, '/quote')
+
+    assert resp.status_code == 200
+    assert resp.json == {
+        'author': 'Grace Hopper',
+        'quote': "I've always been more interested in the future than in the past.",
+    }
+
+
 def test_things(asgi, util):
     suffix = '_asgi' if asgi else ''
     things = util.load_module(f'examples/things{suffix}.py')
diff --git a/tests/test_httperror.py b/tests/test_httperror.py
index 745286b4..9441f32b 100644
--- a/tests/test_httperror.py
+++ b/tests/test_httperror.py
@@ -7,7 +7,12 @@ import xml.etree.ElementTree as et  # noqa: I202
 import pytest
 
 import falcon
+from falcon.constants import MEDIA_JSON
+from falcon.constants import MEDIA_XML
+from falcon.constants import MEDIA_YAML
+from falcon.media import BaseHandler
 import falcon.testing as testing
+from falcon.util.deprecation import DeprecatedWarning
 
 try:
     import yaml
@@ -25,6 +30,23 @@ def client(asgi, util):
     return testing.TestClient(app)
 
 
+@pytest.fixture(
+    params=[
+        pytest.param(True, id='with_xml'),
+        pytest.param(False, id='without_xml'),
+        pytest.param(None, id='default_xml'),
+    ]
+)
+def enable_xml(request):
+    def go(app):
+        if request.param is not None:
+            app.resp_options.xml_error_serialization = request.param
+        # return bool(request.param)
+        return request.param is not False
+
+    return go
+
+
 class FaultyResource:
     def on_get(self, req, resp):
         status = req.get_header('X-Error-Status')
@@ -171,7 +193,7 @@ class LengthRequiredResource:
 
 class RequestEntityTooLongResource:
     def on_get(self, req, resp):
-        raise falcon.HTTPPayloadTooLarge(
+        raise falcon.HTTPContentTooLarge(
             title='Request Rejected', description='Request Body Too Large'
         )
 
@@ -181,7 +203,7 @@ class TemporaryRequestEntityTooLongResource:
         self.retry_after = retry_after
 
     def on_get(self, req, resp):
-        raise falcon.HTTPPayloadTooLarge(
+        raise falcon.HTTPContentTooLarge(
             title='Request Rejected',
             description='Request Body Too Large',
             retry_after=self.retry_after,
@@ -298,9 +320,10 @@ class TestHTTPError:
         response = client.simulate_patch('/fail')
         assert response.status == falcon.HTTP_400
         assert response.json == {'title': '400 Bad Request'}
-        assert response.headers['Content-Type'] == 'application/json'
+        assert response.content_type == 'application/json'
 
     def test_no_description_xml(self, client):
+        client.app.resp_options.xml_error_serialization = True
         response = client.simulate_patch(
             path='/fail', headers={'Accept': 'application/xml'}
         )
@@ -312,7 +335,35 @@ class TestHTTPError:
         )
 
         assert response.content == expected_xml
-        assert response.headers['Content-Type'] == 'application/xml'
+        assert response.content_type == 'application/xml'
+
+    @pytest.mark.parametrize('custom_xml', [True, False])
+    def test_xml_enable(self, client, enable_xml, custom_xml):
+        has_xml = enable_xml(client.app)
+        client.app.resp_options.default_media_type = 'app/foo'
+        accept = 'app/falcon+xml' if custom_xml else 'application/xml'
+        response = client.simulate_patch(path='/fail', headers={'Accept': accept})
+        assert response.status == falcon.HTTP_400
+
+        if has_xml:
+            expected_xml = (
+                b'<?xml version="1.0" encoding="UTF-8"?><error>'
+                b'<title>400 Bad Request</title></error>'
+            )
+            assert response.content == expected_xml
+        else:
+            assert response.content == b''
+        if has_xml or custom_xml:
+            assert response.content_type == 'application/xml'
+        else:
+            assert response.content_type == 'app/foo'
+
+    def test_to_xml_deprecated(self):
+        with pytest.warns(
+            DeprecatedWarning, match='The internal serialization to xml is deprecated.'
+        ):
+            res = falcon.HTTPGone().to_xml()
+        assert res == falcon.HTTPGone()._to_xml()
 
     def test_client_does_not_accept_json_or_xml(self, client):
         headers = {
@@ -495,6 +546,7 @@ class TestHTTPError:
         ],
     )
     def test_epic_fail_xml(self, client, media_type):
+        client.app.resp_options.xml_error_serialization = True
         headers = {'Accept': media_type}
 
         expected_body = (
@@ -543,6 +595,7 @@ class TestHTTPError:
         assert expected_body == response.json
 
     def test_unicode_xml(self, client):
+        client.app.resp_options.xml_error_serialization = True
         unicode_resource = UnicodeFaultyResource()
 
         expected_body = (
@@ -734,11 +787,12 @@ class TestHTTPError:
 
     def test_416(self, client, asgi, util):
         client.app = util.create_app(asgi)
+        client.app.resp_options.xml_error_serialization = True
         client.app.add_route('/416', RangeNotSatisfiableResource())
         response = client.simulate_request(path='/416', headers={'accept': 'text/xml'})
 
         assert response.status == falcon.HTTP_416
-        assert response.content == falcon.HTTPRangeNotSatisfiable(123456).to_xml()
+        assert response.content == falcon.HTTPRangeNotSatisfiable(123456)._to_xml()
         exp = (
             b'<?xml version="1.0" encoding="UTF-8"?><error>'
             b'<title>416 Range Not Satisfiable</title></error>'
@@ -943,7 +997,141 @@ class TestHTTPError:
         err.__cause__ = ValueError('some error')
         assert err.description == 'Could not parse foo-media body - some error'
 
+    def test_kw_only(self):
+        with pytest.raises(TypeError, match='positional argument'):
+            falcon.HTTPError(falcon.HTTP_BAD_REQUEST, 'foo', 'bar')
+
+
+JSON_CONTENT = b'{"title": "410 Gone"}'
+JSON = (MEDIA_JSON, MEDIA_JSON, JSON_CONTENT)
+CUSTOM_JSON = ('custom/any+json', MEDIA_JSON, JSON_CONTENT)
+
+XML_CONTENT = (
+    b'<?xml version="1.0" encoding="UTF-8"?><error><title>410 Gone</title></error>'
+)
+XML = (MEDIA_XML, MEDIA_XML, XML_CONTENT)
+CUSTOM_XML = ('custom/any+xml', MEDIA_XML, XML_CONTENT)
+
+YAML = (MEDIA_YAML, MEDIA_YAML, b'title: 410 Gone!')
+ASYNC_ONLY = ('application/only_async', 'application/only_async', b'this is async')
+ASYNC_WITH_SYNC = (
+    'application/async_with_sync',
+    'application/async_with_sync',
+    b'this is sync instead',
+)
+
+
+class FakeYamlMediaHandler(BaseHandler):
+    def serialize(self, media, content_type=None):
+        assert media == {'title': '410 Gone'}
+        return b'title: 410 Gone!'
+
+
+class AsyncOnlyMediaHandler(BaseHandler):
+    async def serialize_async(self, media, content_type=None):
+        assert media == {'title': '410 Gone'}
+        return b'this is async'
+
+
+class SyncInterfaceMediaHandler(AsyncOnlyMediaHandler):
+    def serialize(self, media, content_type=None):
+        assert media == {'title': '410 Gone'}
+        return b'this is sync instead'
 
-def test_kw_only():
-    with pytest.raises(TypeError, match='positional argument'):
-        falcon.HTTPError(falcon.HTTP_BAD_REQUEST, 'foo', 'bar')
+    _serialize_sync = serialize  # type: ignore[assignment]
+
+
+class TestDefaultSerializeError:
+    @pytest.fixture
+    def client(self, util, asgi):
+        app = util.create_app(asgi)
+        app.add_route('/', GoneResource())
+        return testing.TestClient(app)
+
+    def test_unknown_accept(self, client):
+        res = client.simulate_get(headers={'Accept': 'foo/bar'})
+        assert res.content_type == 'application/json'
+        assert res.headers['vary'] == 'Accept'
+        assert res.content == b''
+
+    @pytest.mark.parametrize('has_json_handler', [True, False])
+    def test_defaults_to_json(self, client, has_json_handler):
+        if not has_json_handler:
+            client.app.req_options.media_handlers.pop(MEDIA_JSON)
+            client.app.resp_options.media_handlers.pop(MEDIA_JSON)
+        res = client.simulate_get()
+        assert res.content_type == 'application/json'
+        assert res.headers['vary'] == 'Accept'
+        assert res.content == JSON_CONTENT
+
+    @pytest.mark.parametrize(
+        'accept, content_type, content',
+        (JSON, XML, CUSTOM_JSON, CUSTOM_XML, YAML, ASYNC_ONLY, ASYNC_WITH_SYNC),
+    )
+    def test_serializes_error_to_preferred_by_sender(
+        self, accept, content_type, content, client, asgi
+    ):
+        client.app.resp_options.xml_error_serialization = True
+        client.app.resp_options.media_handlers[MEDIA_YAML] = FakeYamlMediaHandler()
+        client.app.resp_options.media_handlers[ASYNC_WITH_SYNC[0]] = (
+            SyncInterfaceMediaHandler()
+        )
+        if asgi:
+            client.app.resp_options.media_handlers[ASYNC_ONLY[0]] = (
+                AsyncOnlyMediaHandler()
+            )
+        res = client.simulate_get(headers={'Accept': accept})
+        assert res.headers['vary'] == 'Accept'
+        if content_type == ASYNC_ONLY[0] and not asgi:
+            # media-json is the default content type
+            assert res.content_type == MEDIA_JSON
+            assert res.content == b''
+        else:
+            assert res.content_type == content_type
+            assert res.content == content
+
+    def test_json_async_only_error(self, util):
+        app = util.create_app(True)
+        app.add_route('/', GoneResource())
+        app.resp_options.media_handlers[MEDIA_JSON] = AsyncOnlyMediaHandler()
+        client = testing.TestClient(app)
+        with pytest.raises(NotImplementedError, match='requires the sync interface'):
+            client.simulate_get()
+
+    @pytest.mark.parametrize('accept', [MEDIA_XML, 'application/xhtml+xml'])
+    def test_add_xml_handler(self, client, enable_xml, accept):
+        enable_xml(client.app)
+        client.app.resp_options.media_handlers[MEDIA_XML] = FakeYamlMediaHandler()
+        res = client.simulate_get(headers={'Accept': accept})
+        assert res.content_type == MEDIA_XML
+        assert res.content == YAML[-1]
+
+    @pytest.mark.parametrize(
+        'accept, content_type',
+        [
+            (
+                # firefox
+                'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,'
+                'image/webp,image/png,image/svg+xml,*/*;q=0.8',
+                MEDIA_XML,
+            ),
+            (
+                # safari / chrome
+                'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,'
+                'image/apng,*/*;q=0.8',
+                MEDIA_XML,
+            ),
+            ('text/html, application/xhtml+xml, image/jxr, */*', MEDIA_JSON),  # edge
+            (f'text/html,{MEDIA_YAML};q=0.8,*/*;q=0.7', MEDIA_YAML),
+            (f'text/html,{MEDIA_YAML};q=0.8,{MEDIA_JSON};q=0.8', MEDIA_JSON),
+        ],
+    )
+    def test_hard_content_types(self, client, accept, content_type, enable_xml):
+        has_xml = enable_xml(client.app)
+        client.app.resp_options.default_media_type = 'my_type'
+        client.app.resp_options.media_handlers[MEDIA_YAML] = FakeYamlMediaHandler()
+        res = client.simulate_get(headers={'Accept': accept})
+        if has_xml or content_type != MEDIA_XML:
+            assert res.content_type == content_type
+        else:
+            assert res.content_type == MEDIA_JSON
diff --git a/tests/test_media_handlers.py b/tests/test_media_handlers.py
index 02005530..9fd881b5 100644
--- a/tests/test_media_handlers.py
+++ b/tests/test_media_handlers.py
@@ -9,6 +9,8 @@ import falcon
 from falcon import media
 from falcon import testing
 from falcon.asgi.stream import BoundedStream
+from falcon.constants import MEDIA_JSON
+from falcon.constants import MEDIA_YAML
 
 mujson = None
 orjson = None
@@ -372,6 +374,16 @@ def test_json_err_no_handler(asgi, util):
     assert result.json == falcon.HTTPForbidden().to_dict()
 
 
+def test_handlers_include_new_media_handlers_in_resolving() -> None:
+    handlers = media.Handlers({falcon.MEDIA_URLENCODED: media.URLEncodedFormHandler()})
+    assert handlers._resolve(MEDIA_YAML, MEDIA_JSON, raise_not_found=False)[0] is None
+
+    js_handler = media.JSONHandler()
+    handlers[MEDIA_YAML] = js_handler
+    resolved = handlers._resolve(MEDIA_YAML, MEDIA_JSON, raise_not_found=False)[0]
+    assert resolved is js_handler
+
+
 class TestBaseHandler:
     def test_defaultError(self):
         h = media.BaseHandler()
diff --git a/tests/test_media_multipart.py b/tests/test_media_multipart.py
index 7c408e32..cd269b11 100644
--- a/tests/test_media_multipart.py
+++ b/tests/test_media_multipart.py
@@ -300,11 +300,14 @@ def test_body_part_properties():
             assert part.secure_filename == part.filename
 
 
-def test_empty_filename():
+def test_empty_or_missing_filename():
     data = (
         b'--a0d738bcdb30449eb0d13f4b72c2897e\r\n'
         b'Content-Disposition: form-data; name="file"; filename=\r\n\r\n'
         b'An empty filename.\r\n'
+        b'--a0d738bcdb30449eb0d13f4b72c2897e\r\n'
+        b'Content-Disposition: form-data; name="no file";\r\n\r\n'
+        b'No filename.\r\n'
         b'--a0d738bcdb30449eb0d13f4b72c2897e--\r\n'
     )
 
@@ -313,10 +316,16 @@ def test_empty_filename():
     stream = BufferedReader(io.BytesIO(data).read, len(data))
     form = handler.deserialize(stream, content_type, len(data))
 
+    parts = 0
     for part in form:
-        assert part.filename == ''
+        parts += 1
+        if part.name == 'file':
+            assert part.filename == ''
+        else:
+            assert part.filename is None
         with pytest.raises(falcon.MediaMalformedError):
             part.secure_filename
+    assert parts == 2
 
 
 class MultipartAnalyzer:
diff --git a/tests/test_mediatypes.py b/tests/test_mediatypes.py
index 0fae79b4..7093a517 100644
--- a/tests/test_mediatypes.py
+++ b/tests/test_mediatypes.py
@@ -1,5 +1,8 @@
+import itertools
+
 import pytest
 
+from falcon import errors
 from falcon.util import mediatypes
 
 
@@ -39,3 +42,242 @@ from falcon.util import mediatypes
 )
 def test_parse_header(value, expected):
     assert mediatypes.parse_header(value) == expected
+
+
+def test_media_type_private_cls():
+    mt1 = mediatypes._MediaType.parse('image/png')
+    assert mt1.main_type == 'image'
+    assert mt1.subtype == 'png'
+    assert mt1.params == {}
+
+    mt2 = mediatypes._MediaType.parse('text/plain; charset=latin-1')
+    assert mt2.main_type == 'text'
+    assert mt2.subtype == 'plain'
+    assert mt2.params == {'charset': 'latin-1'}
+
+
+def test_media_range_private_cls():
+    mr1 = mediatypes._MediaRange.parse('image/png')
+    assert mr1.main_type == 'image'
+    assert mr1.subtype == 'png'
+    assert mr1.quality == 1.0
+    assert mr1.params == {}
+
+    mr2 = mediatypes._MediaRange.parse('text/plain; charset=latin-1; Q=0.9')
+    assert mr2.main_type == 'text'
+    assert mr2.subtype == 'plain'
+    assert pytest.approx(mr2.quality) == 0.9
+    assert mr2.params == {'charset': 'latin-1'}
+
+    mr3 = mediatypes._MediaRange.parse('*; q=0.7')
+    assert mr3.main_type == '*'
+    assert mr3.subtype == '*'
+    assert pytest.approx(mr3.quality) == 0.7
+    assert mr3.params == {}
+
+
+_RFC_7231_EXAMPLE_ACCEPT = (
+    'text/*;q=0.3, text/html;q=0.7, text/html;level=1, '
+    'text/html;level=2;q=0.4, */*;q=0.5'
+)
+_RFC_7231_EXAMPLE_VALUES = [
+    ('text/html;level=1', 1.0),
+    ('text/html', 0.7),
+    ('text/plain', 0.3),
+    ('image/jpeg', 0.5),
+    ('text/html;level=2', 0.4),
+    ('text/html;level=3', 0.7),
+]
+
+_RFC_9110_EXAMPLE_ACCEPT = (
+    'text/*;q=0.3, text/plain;q=0.7, text/plain;format=flowed, '
+    'text/plain;format=fixed;q=0.4, */*;q=0.5'
+)
+# NOTE(vytas): Including the errata https://www.rfc-editor.org/errata/eid7138.
+_RFC_9110_EXAMPLE_VALUES = [
+    ('text/plain;format=flowed', 1.0),
+    ('text/plain', 0.7),
+    ('text/html', 0.3),
+    ('image/jpeg', 0.5),
+    ('text/plain;format=fixed', 0.4),
+    ('text/html;level=3', 0.3),
+]
+
+_RFC_EXAMPLES = list(
+    itertools.chain.from_iterable(
+        ((accept,) + media_type_quality for media_type_quality in example_values)
+        for accept, example_values in (
+            (_RFC_7231_EXAMPLE_ACCEPT, _RFC_7231_EXAMPLE_VALUES),
+            (_RFC_9110_EXAMPLE_ACCEPT, _RFC_9110_EXAMPLE_VALUES),
+        )
+    )
+)
+
+_RFC_EXAMPLE_IDS = list(
+    itertools.chain.from_iterable(
+        (
+            f'{rfc}-{media_type}-{quality_value}'
+            for media_type, quality_value in example_values
+        )
+        for rfc, example_values in (
+            ('RFC-7231', _RFC_7231_EXAMPLE_VALUES),
+            ('RFC-9110', _RFC_7231_EXAMPLE_VALUES),
+        )
+    )
+)
+
+
+@pytest.mark.parametrize(
+    'accept,media_type,quality_value', _RFC_EXAMPLES, ids=_RFC_EXAMPLE_IDS
+)
+def test_quality_rfc_examples(accept, media_type, quality_value):
+    assert pytest.approx(mediatypes.quality(media_type, accept)) == quality_value
+
+
+@pytest.mark.parametrize(
+    'accept,media_type,quality_value',
+    [
+        (
+            'application/*, */wildcard; q=0.7, */*; q=0.25',
+            'test/wildcard; expect=pass',
+            0.7,
+        ),
+        (
+            'application/*, */wildcard; q=0.7, */*; q=0.25',
+            'application/wildcard; expect=pass',
+            1.0,
+        ),
+        (
+            'application/*, */wildcard; q=0.7, */*; q=0.25',
+            'test/something; expect=pass',
+            0.25,
+        ),
+        (
+            'text/x-python, text/*; q=0.33, text/plain; format=fixed',
+            'text/plain; format=flowed',
+            0.33,
+        ),
+        (
+            # NOTE(vytas): Same as one of the RFC 7231 examples, just with some
+            #   media ranges reordered. python-mimeparse fails to yield the
+            #   correct result in this specific case.
+            'text/*;q=0.3, text/html;level=1, text/html;q=0.7, '
+            'text/html;level=2;q=0.4, */*;q=0.5',
+            'text/html; level=3',
+            0.7,
+        ),
+    ],
+)
+def test_quality(accept, media_type, quality_value):
+    assert pytest.approx(mediatypes.quality(media_type, accept)) == quality_value
+
+
+@pytest.mark.parametrize(
+    'accept,media_type',
+    [
+        (
+            'foo/bar, test/app; q=0.2, test/app; p=1; q=0.9, test/app;p=1;r=2',
+            'test/app',
+        ),
+        ('test/app; q=0.1, test/app; p=1; q=0.2, test/app;p=1;r=2', 'test/app; p=1'),
+        (
+            '*/app; q=0.1, simple/app; test=true; q=0.2, simple/app; color=blue',
+            'simple/app; test=true',
+        ),
+    ],
+)
+def test_quality_prefer_exact_match(accept, media_type):
+    assert pytest.approx(mediatypes.quality(media_type, accept)) == 0.2
+
+
+_QUALITY_NONE_MATCHES_EXAMPLES = [
+    ('application/json', 'application/yaml'),
+    ('audio/*; q=0.2, audio/basic', 'video/mp3'),
+    (
+        'falcon/peregrine; speed=high; unladen=true',
+        'falcon/peregrine; speed=average',
+    ),
+    ('text/html, text/plain', 'text/x-python'),
+    ('*/json; q=0.2, application/json', 'application/msgpack'),
+    (
+        'text/x-python, image/*; q=0.33, text/plain; format=fixed',
+        'text/plain; format=flowed',
+    ),
+]
+
+
+@pytest.mark.parametrize('accept,media_type', _QUALITY_NONE_MATCHES_EXAMPLES)
+def test_quality_none_matches(accept, media_type):
+    assert mediatypes.quality(media_type, accept) == 0.0
+
+
+@pytest.mark.parametrize(
+    'media_types,accept,expected',
+    [
+        (['application/json'], 'application/json', 'application/json'),
+        (['application/json'], 'application/json; charset=utf-8', 'application/json'),
+        (
+            ['application/json', 'application/yaml'],
+            'application/json, */*; q=0.2',
+            'application/json',
+        ),
+    ],
+)
+def test_best_match(media_types, accept, expected):
+    assert mediatypes.best_match(media_types, accept) == expected
+
+
+_BEST_MATCH_NONE_MATCHES_EXAMPLES = [
+    ([_mt], _acc) for _mt, _acc in _QUALITY_NONE_MATCHES_EXAMPLES
+] + [
+    (['application/json', 'application/yaml'], 'application/xml, text/*; q=0.7'),
+    (
+        ['text/plain', 'falcon/peregrine; load=unladen'],
+        'falcon/peregrine; load=heavy',
+    ),
+]
+
+
+@pytest.mark.parametrize('media_types,accept', _BEST_MATCH_NONE_MATCHES_EXAMPLES)
+def test_best_match_none_matches(media_types, accept):
+    assert mediatypes.best_match(media_types, accept) == ''
+
+
+@pytest.mark.parametrize('media_type', ['', 'word document', 'text'])
+def test_invalid_media_type(media_type):
+    with pytest.raises(errors.InvalidMediaType):
+        mediatypes.quality(media_type, '*/*')
+
+
+def _generate_strings(items):
+    yield from items
+
+
+@pytest.mark.parametrize(
+    'media_range',
+    [
+        '',
+        'word document',
+        'text',
+        'text/plain; q=high',
+        '*/*; q=inf',
+        '*/*; q=-inf',
+        '*/*; q=nan',
+        'application/very-important; q=1337.0',
+    ],
+)
+def test_invalid_media_range(media_range):
+    with pytest.raises(errors.InvalidMediaRange):
+        mediatypes.quality('falcon/peregrine', media_range)
+
+    with pytest.raises(errors.InvalidMediaRange):
+        mediatypes.best_match(_generate_strings(['falcon/peregrine']), media_range)
+
+
+@pytest.mark.parametrize(
+    'accept',
+    ['*/*', 'application/xml, text/*; q=0.7, */*; q=0.3'],
+)
+@pytest.mark.parametrize('media_types', [(), [], {}, _generate_strings(())])
+def test_empty_media_types(accept, media_types):
+    assert mediatypes.best_match(media_types, accept) == ''
diff --git a/tests/test_request_body.py b/tests/test_request_body.py
index 05343ae4..5b333f1b 100644
--- a/tests/test_request_body.py
+++ b/tests/test_request_body.py
@@ -4,7 +4,6 @@ from wsgiref.validate import InputWrapper
 import pytest
 
 import falcon
-from falcon import request_helpers
 import falcon.request
 from falcon.stream import BoundedStream
 import falcon.testing as testing
@@ -110,55 +109,55 @@ class TestRequestBody:
         expected_lines[-1] = expected_lines[-1][:-1]
 
         stream = io.BytesIO(expected_body)
-        body = request_helpers.Body(stream, expected_len)
+        body = BoundedStream(stream, expected_len)
         assert body.read() == expected_body
 
         stream = io.BytesIO(expected_body)
-        body = request_helpers.Body(stream, expected_len)
+        body = BoundedStream(stream, expected_len)
         assert body.read(2) == expected_body[0:2]
 
         stream = io.BytesIO(expected_body)
-        body = request_helpers.Body(stream, expected_len)
+        body = BoundedStream(stream, expected_len)
         assert body.read(expected_len + 1) == expected_body
 
         # NOTE(kgriffs): Test that reading past the end does not
         # hang, but returns the empty string.
         stream = io.BytesIO(expected_body)
-        body = request_helpers.Body(stream, expected_len)
+        body = BoundedStream(stream, expected_len)
         for i in range(expected_len + 1):
             expected_value = expected_body[i : i + 1] if i < expected_len else b''
             assert body.read(1) == expected_value
 
         stream = io.BytesIO(expected_body)
-        body = request_helpers.Body(stream, expected_len)
+        body = BoundedStream(stream, expected_len)
         assert body.readline() == expected_lines[0]
 
         stream = io.BytesIO(expected_body)
-        body = request_helpers.Body(stream, expected_len)
+        body = BoundedStream(stream, expected_len)
         assert body.readline(-1) == expected_lines[0]
 
         stream = io.BytesIO(expected_body)
-        body = request_helpers.Body(stream, expected_len)
+        body = BoundedStream(stream, expected_len)
         assert body.readline(expected_len + 1) == expected_lines[0]
 
         stream = io.BytesIO(expected_body)
-        body = request_helpers.Body(stream, expected_len)
+        body = BoundedStream(stream, expected_len)
         assert body.readlines() == expected_lines
 
         stream = io.BytesIO(expected_body)
-        body = request_helpers.Body(stream, expected_len)
+        body = BoundedStream(stream, expected_len)
         assert body.readlines(-1) == expected_lines
 
         stream = io.BytesIO(expected_body)
-        body = request_helpers.Body(stream, expected_len)
+        body = BoundedStream(stream, expected_len)
         assert body.readlines(expected_len + 1) == expected_lines
 
         stream = io.BytesIO(expected_body)
-        body = request_helpers.Body(stream, expected_len)
+        body = BoundedStream(stream, expected_len)
         assert next(body) == expected_lines[0]
 
         stream = io.BytesIO(expected_body)
-        body = request_helpers.Body(stream, expected_len)
+        body = BoundedStream(stream, expected_len)
         for i, line in enumerate(body):
             assert line == expected_lines[i]
 
diff --git a/tests/test_static.py b/tests/test_static.py
index 1b38d2e6..5830b904 100644
--- a/tests/test_static.py
+++ b/tests/test_static.py
@@ -619,11 +619,11 @@ def test_file_closed(client, patch_open):
     assert patch_open.current_file.closed
 
 
-def test_options_request(util, asgi, patch_open):
+def test_options_request(client, patch_open):
     patch_open()
-    app = util.create_app(asgi, cors_enable=True)
-    app.add_static_route('/static', '/var/www/statics')
-    client = testing.TestClient(app)
+
+    client.app.add_middleware(falcon.CORSMiddleware())
+    client.app.add_static_route('/static', '/var/www/statics')
 
     resp = client.simulate_options(
         path='/static/foo/bar.txt',
diff --git a/tests/test_status_codes.py b/tests/test_status_codes.py
index b8973688..02ca9d5f 100644
--- a/tests/test_status_codes.py
+++ b/tests/test_status_codes.py
@@ -1,6 +1,5 @@
 import http
 import sys
-from typing import Tuple
 
 import pytest
 
@@ -17,12 +16,12 @@ class TestStatusCodes:
         if status_code >= 700:
             pytest.skip('Codes above 700 are not defined in http package')
         http_status = http.HTTPStatus(status_code)
-        if status_code in [413, 418, 422]:
+        if status_code in [418, 422]:
             assert http_status.phrase != message
         else:
             assert http_status.phrase == message
 
-    def _status_code_and_message(self, status: str) -> Tuple[int, str]:
+    def _status_code_and_message(self, status: str):
         status = getattr(status_codes, status)
         value, message = status.split(' ', 1)
         return int(value), message
diff --git a/tests/test_testing.py b/tests/test_testing.py
index dda7e188..89a8c49e 100644
--- a/tests/test_testing.py
+++ b/tests/test_testing.py
@@ -4,6 +4,7 @@ import falcon
 from falcon import App
 from falcon import status_codes
 from falcon import testing
+from falcon.util.sync import async_to_sync
 
 
 class CustomCookies:
@@ -103,6 +104,32 @@ def test_simulate_request_content_type():
     assert result.text == falcon.MEDIA_JSON
 
 
+@pytest.mark.parametrize('mode', ['wsgi', 'asgi', 'asgi-stream'])
+def test_content_type(util, mode):
+    class Responder:
+        def on_get(self, req, resp):
+            resp.content_type = req.content_type
+
+    app = util.create_app('asgi' in mode)
+    app.add_route('/', Responder())
+
+    if 'stream' in mode:
+
+        async def go():
+            async with testing.ASGIConductor(app) as ac:
+                async with ac.simulate_get_stream(
+                    '/', content_type='my-content-type'
+                ) as r:
+                    assert r.content_type == 'my-content-type'
+            return 1
+
+        assert async_to_sync(go) == 1
+    else:
+        client = testing.TestClient(app)
+        res = client.simulate_get('/', content_type='foo-content')
+        assert res.content_type == 'foo-content'
+
+
 @pytest.mark.parametrize('cookies', [{'foo': 'bar', 'baz': 'foo'}, CustomCookies()])
 def test_create_environ_cookies(cookies):
     environ = testing.create_environ(cookies=cookies)