Repository: falconry/falcon
Pull request #1846 (2020-12-30 21:26:07)

perf(asgi): Optimize media (de)serialization
# Summary of Changes

This patch optimizes the media (de)serialization paths for ASGI apps by
avoiding an extra await and slightly flattening the call stack.

BREAKING CHANGE: Private member variables for the default media handlers were
    renamed. This should only affect subclasses, as these variables were never
    documented, and were not intended to be part of the public interface.

# Related Issues

#1822

# Pull Request Checklist

This is just a reminder about the most common mistakes.  Please make sure that you tick all *appropriate* boxes.  But please read our [contribution guide](../CONTRIBUTING.md) at least once; it will save you a few review cycles!

If an item doesn't apply to your pull request, **check it anyway** to make it apparent that there's nothing to do.

- [x] Applied changes to both WSGI and ASGI code paths and interfaces (where applicable).
- [x] Added **tests** for changed code.
- [x] Prefixed code comments with GitHub nick and an appropriate prefix.
- [x] Coding style is consistent with the rest of the framework.
- [x] Updated **documentation** for changed code.
    - [x] Added docstrings for any new classes, functions, or modules.
    - [x] Updated docstrings for any modifications to existing code.
    - [x] Updated both WSGI and ASGI docs (where applicable).
    - [x] Added references to new classes, functions, or modules to the relevant RST file under `docs/`.
    - [x] Updated all relevant supporting documentation files under `docs/`.
    - [x] A copyright notice is included at the top of any new modules (using your own name or the name of your organization).
    - [x] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/usage/restructuredtext/directives.html?highlight=versionadded#directive-versionadded).
- [x] Changes (and possible deprecations) have [towncrier](https://towncrier.readthedocs.io/en/actual-freaking-docs/index.html) news fragments under `docs/_newsfragments/`, with the file name format `{issue_number}.{fragment_type}.rst`. (Run `towncrier --draft` to ensure it renders correctly.)

If you have *any* questions to *any* of the points above, just **submit and ask**! This checklist is here to *help* you, not to deter you from contributing!

*PR template inspired by the attrs project.*


codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1846?src=pr&el=h1) Report
> Merging [#1846](https://codecov.io/gh/falconry/falcon/pull/1846?src=pr&el=desc) (ab6e1ea) into [master](https://codecov.io/gh/falconry/falcon/commit/0d6f530dc0e1dbf7e584b13e3ca1a0ad98f7bb42?el=desc) (0d6f530) will **decrease** coverage by `65.64%`.
> The diff coverage is `20.40%`.

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

```diff
@@             Coverage Diff              @@
##            master    #1846       +/-   ##
============================================
- Coverage   100.00%   34.35%   -65.65%     
============================================
  Files           55       55               
  Lines         5164     5187       +23     
  Branches       833      837        +4     
============================================
- Hits          5164     1782     -3382     
- Misses           0     3365     +3365     
- Partials         0       40       +40     
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1846?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/media/msgpack.py](https://codecov.io/gh/falconry/falcon/pull/1846/diff?src=pr&el=tree#diff-ZmFsY29uL21lZGlhL21zZ3BhY2sucHk=) | `44.44% <0.00%> (-55.56%)` | :arrow_down: |
| [falcon/asgi/response.py](https://codecov.io/gh/falconry/falcon/pull/1846/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvcmVzcG9uc2UucHk=) | `27.02% <13.33%> (-72.98%)` | :arrow_down: |
| [falcon/asgi/request.py](https://codecov.io/gh/falconry/falcon/pull/1846/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvcmVxdWVzdC5weQ==) | `23.34% <20.00%> (-76.66%)` | :arrow_down: |
| [falcon/media/json.py](https://codecov.io/gh/falconry/falcon/pull/1846/diff?src=pr&el=tree#diff-ZmFsY29uL21lZGlhL2pzb24ucHk=) | `61.36% <46.15%> (-38.64%)` | :arrow_down: |
| [falcon/util/reader.py](https://codecov.io/gh/falconry/falcon/pull/1846/diff?src=pr&el=tree#diff-ZmFsY29uL3V0aWwvcmVhZGVyLnB5) | `12.62% <0.00%> (-87.38%)` | :arrow_down: |
| [falcon/asgi/stream.py](https://codecov.io/gh/falconry/falcon/pull/1846/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvc3RyZWFtLnB5) | `13.58% <0.00%> (-86.42%)` | :arrow_down: |
| [falcon/asgi/structures.py](https://codecov.io/gh/falconry/falcon/pull/1846/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvc3RydWN0dXJlcy5weQ==) | `14.58% <0.00%> (-85.42%)` | :arrow_down: |
| [falcon/middleware.py](https://codecov.io/gh/falconry/falcon/pull/1846/diff?src=pr&el=tree#diff-ZmFsY29uL21pZGRsZXdhcmUucHk=) | `14.89% <0.00%> (-85.11%)` | :arrow_down: |
| [falcon/asgi/reader.py](https://codecov.io/gh/falconry/falcon/pull/1846/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvcmVhZGVyLnB5) | `16.00% <0.00%> (-84.00%)` | :arrow_down: |
| [falcon/testing/helpers.py](https://codecov.io/gh/falconry/falcon/pull/1846/diff?src=pr&el=tree#diff-ZmFsY29uL3Rlc3RpbmcvaGVscGVycy5weQ==) | `18.63% <0.00%> (-81.37%)` | :arrow_down: |
| ... and [41 more](https://codecov.io/gh/falconry/falcon/pull/1846/diff?src=pr&el=tree-more) | |

------

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

kgriffs: In my tests, this optimization shaves off ~5-10 µs. It would be interesting to see what cachegrind says, though.
CaselIT: This is interesting, but is not very scalable. 
I think we could rethink the handler interface a bit to support this pattern, since it should be the handler that should indicate if it support fast access to this.

Also it does not take into consideration that the handler may change after application startup
kgriffs: In order to improve cohesion and reduce coupling, I have refactored the implementation to use a protocol-based approach, where the handlers themselves can opt-in to the optimization and specify the sync callables to be used. This simplifies the logic in the Request and Response classes while also avoiding a brittle dependency on handler internals
CaselIT: I think we could do the normalization on the Handler class. I'll look into it
CaselIT: Take a look at https://github.com/CaselIT/falcon/commit/257a820141961a88ef3ca977a88c30f812da75f4

cc @vytas7 
kgriffs: I pulled in the patches from @CaselIT and then refactored to use lru_cache(). This seems to have done the trick, but please let me know how it benchmarks on your systems.
kgriffs: Incorporated additional excellent feedback from @vytas7 and @CaselIT.
kgriffs: Regarding performance testing, on my laptop I was able to verify improvements on py36 and py38 with or without cythonization.

Under PyPy I found that performance decreased by about 120 ns since the lru cache was that much slower as compared to the removed simple `self.data` optimization shown below.

```py
        try:
            return self.data[media_type]
        except KeyError:
            pass
```

However, when the simple check fails, the lru provides a large performance boost (roughly going from 4 µs to 440 ns). Therefore, given that the penalty in the common case is negligible, I am inclined to keep using the lru under PyPy.
vytas7: Hm, isn't that simple case very common though? I.e. simply using `application/json` is probably what 99% of JSON API users are going to do...

I don't know how much it is worth optimizing this, but maybe we could detect PyPy and shim `_resolve` with smth along the lines of
```python
try:
    return self.data[media_type]
except KeyError:
    return self._cached_resolve(...)
```


CaselIT: > Hm, isn't that simple case very common though? I.e. simply using `application/json` is probably what 99% of JSON API users are going to do...
> 
> I don't know how much it is worth optimizing this, but maybe we could detect PyPy and shim `_resolve` with smth along the lines of
> ```python
> try:
>     return self.data[media_type]
> except KeyError:
>     return self._cached_resolve(...)
> ```
> 
> 

I think we could just have this logic for all implementations, since with the lru cache the function is not called often
kgriffs: I went ahead and attempted to better improve PyPy performance since discovering that the penalty was a little worse under the newer PyPy 7.3.3 release, and because I pride myself on making poor life choices...

If you guys have time, please benchmark this last batch of changes to keep me honest. At this point I think we have reached the point of diminishing returns, so hopefully we can tie a bow on this PR soon.

Patch:
diff --git a/docs/_newsfragments/1822.breakingchange.rst b/docs/_newsfragments/1822.breakingchange.rst
new file mode 100644
index 0000000..301256d
--- /dev/null
+++ b/docs/_newsfragments/1822.breakingchange.rst
@@ -0,0 +1,5 @@
+The private attributes for :class:`~.falcon.media.JSONHandler` were renamed, and
+the private attributes used by :class:`~.falcon.media.MessagePackHandler` were
+replaced. Subclasses that refer to these variables will need to be updated. In
+addition, the undocumented :meth:`falcon.media.Handlers.find_by_media_type`
+method was deprecated and may be removed in a future release.
diff --git a/docs/conf.py b/docs/conf.py
index 62c086c..ac06b11 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -67,8 +67,9 @@ extensions = [
     'sphinx_tabs.tabs',
 
     # Falcon-specific extensions
-    'ext.rfc',
     'ext.doorway',
+    'ext.private_args',
+    'ext.rfc',
 ]
 
 # Add any paths that contain templates here, relative to this directory.
diff --git a/docs/ext/private_args.py b/docs/ext/private_args.py
new file mode 100644
index 0000000..0fce55d
--- /dev/null
+++ b/docs/ext/private_args.py
@@ -0,0 +1,40 @@
+# Copyright 2021 by Kurt Griffiths
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# 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 args module extension for Sphinx.
+
+This extension exludes underscore-prefixed args and kwargs from the function
+signature.
+"""
+
+
+def _on_process_signature(app, what, name, obj, options, signature, return_annotation):
+    if what in ('function', 'method') and signature and '_' in signature:
+        filtered = []
+
+        for token in signature[1:-1].split(','):
+            token = token.strip()
+
+            if not token.startswith('_'):
+                filtered.append(token)
+
+        signature = f'({", ".join(filtered)})'
+
+    return signature, return_annotation
+
+
+def setup(app):
+    app.connect('autodoc-process-signature', _on_process_signature)
+
+    return {'parallel_read_safe': True}
diff --git a/falcon/__init__.py b/falcon/__init__.py
index c90d708..addd98a 100644
--- a/falcon/__init__.py
+++ b/falcon/__init__.py
@@ -25,7 +25,6 @@ the framework's classes, functions, and variables::
 """
 
 import logging as _logging
-import sys as _sys
 
 # Hoist classes and functions into the falcon namespace
 from falcon.version import __version__  # NOQA
@@ -51,10 +50,6 @@ from falcon.request import Request, RequestOptions, Forwarded  # NOQA
 from falcon.response import Response, ResponseOptions  # NOQA
 
 
-ASGI_SUPPORTED = _sys.version_info >= (3, 6)
-"""Set to ``True`` when ASGI is supported for the current Python version."""
-
-
 # NOTE(kgriffs): Only to be used internally on the rare occasion that we
 #   need to log something that we can't communicate any other way.
 _logger = _logging.getLogger('falcon')
diff --git a/falcon/app_helpers.py b/falcon/app_helpers.py
index c3e8f8f..47abf47 100644
--- a/falcon/app_helpers.py
+++ b/falcon/app_helpers.py
@@ -236,7 +236,7 @@ def default_serialize_error(req, resp, exception):
 
     if preferred is not None:
         if preferred == MEDIA_JSON:
-            handler = resp.options.media_handlers.find_by_media_type(
+            handler, _, _ = resp.options.media_handlers._resolve(
                 MEDIA_JSON, MEDIA_JSON, raise_not_found=False
             )
             resp.data = exception.to_json(handler)
diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py
index ab1a1d5..cad992e 100644
--- a/falcon/asgi/app.py
+++ b/falcon/asgi/app.py
@@ -554,7 +554,7 @@ class App(falcon.app.App):
 
             self._schedule_callbacks(resp)
 
-            handler = self.resp_options.media_handlers.find_by_media_type(
+            handler, _, _ = self.resp_options.media_handlers._resolve(
                 MEDIA_JSON, MEDIA_JSON, raise_not_found=False
             )
 
diff --git a/falcon/asgi/multipart.py b/falcon/asgi/multipart.py
index 6253907..15fba81 100644
--- a/falcon/asgi/multipart.py
+++ b/falcon/asgi/multipart.py
@@ -40,7 +40,7 @@ class BodyPart(multipart.BodyPart):
 
     async def get_media(self):
         if self._media is None:
-            handler = self._parse_options.media_handlers.find_by_media_type(
+            handler, _, _ = self._parse_options.media_handlers._resolve(
                 self.content_type, 'text/plain')
 
             try:
diff --git a/falcon/asgi/request.py b/falcon/asgi/request.py
index 9d1102e..62a9d96 100644
--- a/falcon/asgi/request.py
+++ b/falcon/asgi/request.py
@@ -19,6 +19,7 @@ from falcon import request_helpers as helpers  # NOQA: Required by fixed up WSGI
 from falcon.constants import SINGLETON_HEADERS
 from falcon.forwarded import _parse_forwarded_header  # NOQA: Req. by fixed up WSGI Request attrs
 from falcon.forwarded import Forwarded  # NOQA
+import falcon.media
 import falcon.request
 from falcon.util.uri import parse_host, parse_query_string
 from . import _request_helpers as asgi_helpers
@@ -750,17 +751,21 @@ class Request(falcon.request.Request):
                 return default_when_empty
             raise self._media_error
 
-        handler = self.options.media_handlers.find_by_media_type(
+        handler, _, deserialize_sync = self.options.media_handlers._resolve(
             self.content_type,
             self.options.default_media_type
         )
 
         try:
-            self._media = await handler.deserialize_async(
-                self.stream,
-                self.content_type,
-                self.content_length
-            )
+            if deserialize_sync:
+                self._media = deserialize_sync(await self.stream.read())
+            else:
+                self._media = await handler.deserialize_async(
+                    self.stream,
+                    self.content_type,
+                    self.content_length
+                )
+
         except errors.MediaNotFoundError as err:
             self._media_error = err
             if default_when_empty is not _UNSET:
diff --git a/falcon/asgi/response.py b/falcon/asgi/response.py
index fd18517..111e0b0 100644
--- a/falcon/asgi/response.py
+++ b/falcon/asgi/response.py
@@ -18,6 +18,7 @@ from asyncio.coroutines import CoroWrapper  # type: ignore
 from inspect import iscoroutine, iscoroutinefunction
 
 from falcon.constants import _UNSET
+import falcon.media
 import falcon.response
 from falcon.util.misc import is_python_func
 
@@ -262,15 +263,18 @@ class Response(falcon.response.Response):
                     if not self.content_type:
                         self.content_type = self.options.default_media_type
 
-                    handler = self.options.media_handlers.find_by_media_type(
+                    handler, serialize_sync, _ = self.options.media_handlers._resolve(
                         self.content_type,
                         self.options.default_media_type
                     )
 
-                    self._media_rendered = await handler.serialize_async(
-                        self._media,
-                        self.content_type
-                    )
+                    if serialize_sync:
+                        self._media_rendered = serialize_sync(self._media)
+                    else:
+                        self._media_rendered = await handler.serialize_async(
+                            self._media,
+                            self.content_type
+                        )
 
                 data = self._media_rendered
         else:
diff --git a/falcon/bench/bench.py b/falcon/bench/bench.py
index a051363..612320c 100755
--- a/falcon/bench/bench.py
+++ b/falcon/bench/bench.py
@@ -49,6 +49,7 @@ except ImportError:
     vmprof = None
 
 from falcon.bench import create  # NOQA
+from falcon.constants import PYPY
 import falcon.testing as helpers
 
 
@@ -64,8 +65,6 @@ ITER_DETECTION_DURATION_MAX = 6.0
 
 JIT_WARMING_MULTIPLIER = 30
 
-PYPY = platform.python_implementation() == 'PyPy'
-
 BODY = helpers.rand_string(10240, 10240).encode('utf-8') # NOQA
 HEADERS = {'X-Test': 'Funky Chicken'}  # NOQA
 
diff --git a/falcon/constants.py b/falcon/constants.py
index 06209e0..42bf581 100644
--- a/falcon/constants.py
+++ b/falcon/constants.py
@@ -1,5 +1,13 @@
 from enum import Enum
 import os
+import sys
+
+
+PYPY = (sys.implementation.name == 'pypy')
+"""Evaluates to ``True`` when the current Python implementation is PyPy."""
+
+ASGI_SUPPORTED = sys.version_info >= (3, 6)
+"""Evaluates to ``True`` when ASGI is supported for the current Python version."""
 
 # RFC 7231, 5789 methods
 HTTP_METHODS = [
diff --git a/falcon/media/base.py b/falcon/media/base.py
index 3258a61..6e590b5 100644
--- a/falcon/media/base.py
+++ b/falcon/media/base.py
@@ -8,7 +8,20 @@ from falcon.constants import MEDIA_JSON
 class BaseHandler(metaclass=abc.ABCMeta):
     """Abstract Base Class for an internet media type handler."""
 
-    def serialize(self, media, content_type):
+    # NOTE(kgriffs): The following special methods are used to enable an
+    #   optimized media (de)serialization protocol for ASGI. This is not
+    #   currently part of the public interface for Falcon, and is not
+    #   included in the docs. Once we are happy with the protocol, we
+    #   might make it part of the public interface for use by custom
+    #   media type handlers.
+
+    _serialize_sync = None
+    """Override to provide a synchronous serialization method that takes an object."""
+
+    _deserialize_sync = None
+    """Override to provide a synchronous deserialization method that takes a byte string."""
+
+    def serialize(self, media, content_type) -> bytes:
         """Serialize the media object on a :any:`falcon.Response`.
 
         By default, this method raises an instance of
@@ -39,7 +52,7 @@ class BaseHandler(metaclass=abc.ABCMeta):
         else:
             raise NotImplementedError()
 
-    async def serialize_async(self, media, content_type):
+    async def serialize_async(self, media, content_type) -> bytes:
         """Serialize the media object on a :any:`falcon.Response`.
 
         This method is similar to :py:meth:`~.BaseHandler.serialize`
@@ -66,7 +79,7 @@ class BaseHandler(metaclass=abc.ABCMeta):
         """
         return self.serialize(media, content_type)
 
-    def deserialize(self, stream, content_type, content_length):
+    def deserialize(self, stream, content_type, content_length) -> object:
         """Deserialize the :any:`falcon.Request` body.
 
         By default, this method raises an instance of
@@ -98,7 +111,7 @@ class BaseHandler(metaclass=abc.ABCMeta):
         else:
             raise NotImplementedError()
 
-    async def deserialize_async(self, stream, content_type, content_length):
+    async def deserialize_async(self, stream, content_type, content_length) -> object:
         """Deserialize the :any:`falcon.Request` body.
 
         This method is similar to :py:meth:`~.BaseHandler.deserialize` except
diff --git a/falcon/media/handlers.py b/falcon/media/handlers.py
index a5dd9e2..134172a 100644
--- a/falcon/media/handlers.py
+++ b/falcon/media/handlers.py
@@ -1,10 +1,12 @@
 from collections import UserDict
 
 from falcon import errors
-from falcon.constants import MEDIA_JSON, MEDIA_MULTIPART, MEDIA_URLENCODED
+from falcon.constants import MEDIA_JSON, MEDIA_MULTIPART, MEDIA_URLENCODED, PYPY
 from falcon.media.json import JSONHandler
 from falcon.media.multipart import MultipartFormHandler, MultipartParseOptions
 from falcon.media.urlencoded import URLEncodedFormHandler
+from falcon.util import deprecation
+from falcon.util import misc
 from falcon.vendor import mimeparse
 
 
@@ -29,6 +31,8 @@ class MissingDependencyHandler:
 class Handlers(UserDict):
     """A :class:`dict`-like object that manages Internet media type handlers."""
     def __init__(self, initial=None):
+        self._resolve = self._create_resolver()
+
         handlers = initial or {
             MEDIA_JSON: JSONHandler(),
             MEDIA_MULTIPART: MultipartFormHandler(),
@@ -39,21 +43,74 @@ class Handlers(UserDict):
         # Also, this results in self.update(...) being called.
         UserDict.__init__(self, handlers)
 
-    def _resolve_media_type(self, media_type, all_media_types):
-        resolved = None
-
-        try:
-            # NOTE(jmvrbanac): Mimeparse will return an empty string if it can
-            # parse the media type, but cannot find a suitable type.
-            resolved = mimeparse.best_match(
-                all_media_types,
-                media_type
+    def __setitem__(self, key, value):
+        super().__setitem__(key, value)
+
+        # NOTE(kgriffs): When the mapping changes, we do not want to use a
+        #   cached handler from the previous mapping, in case it was
+        #   replaced.
+        self._resolve.cache_clear()
+
+    def __delitem__(self, key):
+        super().__delitem__(key)
+
+        # NOTE(kgriffs): Similar to __setitem__(), we need to avoid resolving
+        #   to a cached handler that was removed.
+        self._resolve.cache_clear()
+
+    def _create_resolver(self):
+        # PERF(kgriffs): Under PyPy the LRU is relatively expensive as compared
+        #   to the common case of the self.data lookup succeeding. Using
+        #   _lru_cache_for_simple_logic() takes this into account by essentially
+        #   creating a nop but also decorating the method with a dummy
+        #   cache_clear().
+        # PERF(kgriffs): Most apps will probably only use one or two media handlers,
+        #   but we use maxsize=64 to give us some wiggle room just in case someone
+        #   is using versioned media types or something, and to cover various
+        #   combinations of the method args. We may need to tune this later.
+        @misc._lru_cache_for_simple_logic(maxsize=64)
+        def resolve(media_type, default, raise_not_found=True):
+            if media_type == '*/*' or not media_type:
+                media_type = default
+
+            # PERF(kgriffs): Under CPython we do not need this shortcut to
+            #   improve performance since most calls will be resolved by the
+            #   LRU cache on resolve(). On the other hand, it doesn't hurt,
+            #   and it certainly makes a difference under PyPy.
+            try:
+                handler = self.data[media_type]
+            except KeyError:
+                handler = None
+
+            if not handler:
+                # PERF(kgriffs): We just do this slower check every time, rather
+                #   than trying to first check the dict directly, since the result
+                #   will almost always be cached anyway.
+                # NOTE(kgriffs): Wrap keys in a tuple to make them hashable.
+                matched_type = _best_match(media_type, tuple(self.data.keys()))
+
+                if not matched_type:
+                    if raise_not_found:
+                        raise errors.HTTPUnsupportedMediaType(
+                            description='{0} is an unsupported media type.'.format(media_type)
+                        )
+
+                    return None, None, None
+
+                handler = self.data[matched_type]
+
+            return (
+                handler,
+                getattr(handler, '_serialize_sync', None),
+                getattr(handler, '_deserialize_sync', None),
             )
-        except ValueError:
-            pass
 
-        return resolved
+        return resolve
 
+    @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, default, raise_not_found=True):
         # PERF(jmvrbanac): Check via a quick methods first for performance
         if media_type == '*/*' or not media_type:
@@ -64,8 +121,9 @@ class Handlers(UserDict):
         except KeyError:
             pass
 
-        # PERF(jmvrbanac): Fallback to the slower method
-        resolved = self._resolve_media_type(media_type, self.data.keys())
+        # 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:
@@ -77,6 +135,34 @@ class Handlers(UserDict):
         return self.data[resolved]
 
 
+def _best_match(media_type, all_media_types):
+    result = None
+
+    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
+        )
+    except ValueError:
+        pass
+
+    return result
+
+
+if PYPY:
+    # NOTE(kgriffs): The most common case for resolve() is that the
+    #   direct self.data shortcut will succeed. In this case, the LRU
+    #   lookup for resolve() is actually slower under PyPy than just
+    #   executing the method's body each time.
+    #
+    #   However, if the shortcut does not succeed, invoking best_match()
+    #   is relatively expensive, so it does make sense to use an LRU
+    #   in that case.
+    _best_match = misc._lru_cache_safe(maxsize=64)(_best_match)  # pragma: nocover
+
+
 # NOTE(vytas): An ugly way to work around circular imports.
 MultipartParseOptions._DEFAULT_HANDLERS = Handlers({
     MEDIA_JSON: JSONHandler(),
diff --git a/falcon/media/json.py b/falcon/media/json.py
index b2c55b6..1b57cd4 100644
--- a/falcon/media/json.py
+++ b/falcon/media/json.py
@@ -71,12 +71,12 @@ class JSONHandler(BaseHandler):
     """
 
     def __init__(self, dumps=None, loads=None):
-        self.dumps = dumps or partial(json.dumps, ensure_ascii=False)
-        self.loads = loads or json.loads
+        self._dumps = dumps or partial(json.dumps, ensure_ascii=False)
+        self._loads = loads or json.loads
 
         # PERF(kgriffs): Test dumps once up front so we can set the
         #     proper serialize implementation.
-        result = self.dumps({'message': 'Hello World'})
+        result = self._dumps({'message': 'Hello World'})
         if isinstance(result, str):
             self.serialize = self._serialize_s
             self.serialize_async = self._serialize_async_s
@@ -84,11 +84,17 @@ class JSONHandler(BaseHandler):
             self.serialize = self._serialize_b
             self.serialize_async = self._serialize_async_b
 
+        # NOTE(kgriffs): To be safe, only enable the optimized protocol when
+        #   not subclassed.
+        if type(self) is JSONHandler:
+            self._serialize_sync = self.serialize
+            self._deserialize_sync = self._deserialize
+
     def _deserialize(self, data):
         if not data:
             raise errors.MediaNotFoundError('JSON')
         try:
-            return self.loads(data.decode())
+            return self._loads(data.decode())
         except ValueError as err:
             raise errors.MediaMalformedError('JSON') from err
 
@@ -98,17 +104,19 @@ class JSONHandler(BaseHandler):
     async def deserialize_async(self, stream, content_type, content_length):
         return self._deserialize(await stream.read())
 
-    def _serialize_s(self, media, content_type):
-        return self.dumps(media).encode()
+    # NOTE(kgriffs): Make content_type a kwarg to support the
+    #   Request.render_body() shortcut optimization.
+    def _serialize_s(self, media, content_type=None) -> bytes:
+        return self._dumps(media).encode()
 
-    async def _serialize_async_s(self, media, content_type):
-        return self.dumps(media).encode()
+    async def _serialize_async_s(self, media, content_type) -> bytes:
+        return self._dumps(media).encode()
 
-    def _serialize_b(self, media, content_type):
-        return self.dumps(media)
+    def _serialize_b(self, media, content_type) -> bytes:
+        return self._dumps(media)
 
-    async def _serialize_async_b(self, media, content_type):
-        return self.dumps(media)
+    async def _serialize_async_b(self, media, content_type) -> bytes:
+        return self._dumps(media)
 
 
 class JSONHandlerWS(TextBaseHandlerWS):
@@ -170,14 +178,14 @@ class JSONHandlerWS(TextBaseHandlerWS):
     __slots__ = ['dumps', 'loads']
 
     def __init__(self, dumps=None, loads=None):
-        self.dumps = dumps or partial(json.dumps, ensure_ascii=False)
-        self.loads = loads or json.loads
+        self._dumps = dumps or partial(json.dumps, ensure_ascii=False)
+        self._loads = loads or json.loads
 
     def serialize(self, media: object) -> str:
-        return self.dumps(media)
+        return self._dumps(media)
 
     def deserialize(self, payload: str) -> object:
-        return self.loads(payload)
+        return self._loads(payload)
 
 
 http_error._DEFAULT_JSON_HANDLER = _DEFAULT_JSON_HANDLER = JSONHandler()  # type: ignore
diff --git a/falcon/media/msgpack.py b/falcon/media/msgpack.py
index 3d69b07..4b5cf4a 100644
--- a/falcon/media/msgpack.py
+++ b/falcon/media/msgpack.py
@@ -30,8 +30,15 @@ class MessagePackHandler(BaseHandler):
     def __init__(self):
         import msgpack
 
-        self.msgpack = msgpack
-        self.packer = msgpack.Packer(autoreset=True, use_bin_type=True)
+        packer = msgpack.Packer(autoreset=True, use_bin_type=True)
+        self._pack = packer.pack
+        self._unpackb = msgpack.unpackb
+
+        # NOTE(kgriffs): To be safe, only enable the optimized protocol when
+        #   not subclassed.
+        if type(self) is MessagePackHandler:
+            self._serialize_sync = self._pack
+            self._deserialize_sync = self._deserialize
 
     def _deserialize(self, data):
         if not data:
@@ -39,7 +46,7 @@ class MessagePackHandler(BaseHandler):
         try:
             # NOTE(jmvrbanac): Using unpackb since we would need to manage
             # a buffer for Unpacker() which wouldn't gain us much.
-            return self.msgpack.unpackb(data, raw=False)
+            return self._unpackb(data, raw=False)
         except ValueError as err:
             raise errors.MediaMalformedError('MessagePack') from err
 
@@ -49,11 +56,11 @@ class MessagePackHandler(BaseHandler):
     async def deserialize_async(self, stream, content_type, content_length):
         return self._deserialize(await stream.read())
 
-    def serialize(self, media, content_type):
-        return self.packer.pack(media)
+    def serialize(self, media, content_type) -> bytes:
+        return self._pack(media)
 
-    async def serialize_async(self, media, content_type):
-        return self.packer.pack(media)
+    async def serialize_async(self, media, content_type) -> bytes:
+        return self._pack(media)
 
 
 class MessagePackHandlerWS(BinaryBaseHandlerWS):
@@ -78,13 +85,14 @@ class MessagePackHandlerWS(BinaryBaseHandlerWS):
     def __init__(self):
         import msgpack
 
-        self.msgpack = msgpack
-        self.packer = msgpack.Packer(autoreset=True, use_bin_type=True)
+        packer = msgpack.Packer(autoreset=True, use_bin_type=True)
+        self._pack = packer.pack
+        self._unpackb = msgpack.unpackb
 
     def serialize(self, media: object) -> Union[bytes, bytearray, memoryview]:
-        return self.packer.pack(media)
+        return self._pack(media)
 
     def deserialize(self, payload: bytes) -> object:
         # NOTE(jmvrbanac): Using unpackb since we would need to manage
         #   a buffer for Unpacker() which wouldn't gain us much.
-        return self.msgpack.unpackb(payload, raw=False)
+        return self._unpackb(payload, raw=False)
diff --git a/falcon/media/multipart.py b/falcon/media/multipart.py
index 820a6ee..02493fb 100644
--- a/falcon/media/multipart.py
+++ b/falcon/media/multipart.py
@@ -371,7 +371,7 @@ class BodyPart:
             object: The deserialized media representation.
         """
         if self._media is None:
-            handler = self._parse_options.media_handlers.find_by_media_type(
+            handler, _, _ = self._parse_options.media_handlers._resolve(
                 self.content_type, 'text/plain')
 
             try:
diff --git a/falcon/media/urlencoded.py b/falcon/media/urlencoded.py
index b5cf508..7b49b8f 100644
--- a/falcon/media/urlencoded.py
+++ b/falcon/media/urlencoded.py
@@ -6,17 +6,21 @@ from falcon.util.uri import parse_query_string
 
 
 class URLEncodedFormHandler(BaseHandler):
-    """
-    URL-encoded form data handler.
+    """URL-encoded form data handler.
 
     This handler parses ``application/x-www-form-urlencoded`` HTML forms to a
-    ``dict`` in a similar way that URL query parameters are parsed. An empty body
+    ``dict``, similar to how URL query parameters are parsed. An empty body
     will be parsed as an empty dict.
 
     When deserializing, this handler will raise :class:`falcon.MediaMalformedError`
     if the request payload cannot be parsed as ASCII or if any of the URL-encoded
     strings in the payload are not valid UTF-8.
 
+    As documented for :any:`urllib.parse.urlencode`, when serializing, the
+    media object must either be a ``dict`` or a sequence of two-element
+    ``tuple``'s. If any values in the media object are sequences, each
+    sequence element is converted to a separate parameter.
+
     Keyword Arguments:
         keep_blank (bool): Whether to keep empty-string values from the form
             when deserializing.
@@ -25,13 +29,21 @@ class URLEncodedFormHandler(BaseHandler):
     """
 
     def __init__(self, keep_blank=True, csv=False):
-        self.keep_blank = keep_blank
-        self.csv = csv
+        self._keep_blank = keep_blank
+        self._csv = csv
+
+        # NOTE(kgriffs): To be safe, only enable the optimized protocol when
+        #   not subclassed.
+        if type(self) is URLEncodedFormHandler:
+            self._serialize_sync = self.serialize
+            self._deserialize_sync = self._deserialize
 
-    def serialize(self, media, content_type):
+    # NOTE(kgriffs): Make content_type a kwarg to support the
+    #   Request.render_body() shortcut optimization.
+    def serialize(self, media, content_type=None) -> bytes:
         # NOTE(vytas): Setting doseq to True to mirror the parse_query_string
         # behaviour.
-        return urlencode(media, doseq=True)
+        return urlencode(media, doseq=True).encode()
 
     def _deserialize(self, body):
         try:
@@ -40,7 +52,7 @@ class URLEncodedFormHandler(BaseHandler):
             # catch malicious input.
             body = body.decode('ascii')
             return parse_query_string(
-                body, keep_blank=self.keep_blank, csv=self.csv
+                body, keep_blank=self._keep_blank, csv=self._csv
             )
         except Exception as err:
             raise errors.MediaMalformedError('URL-encoded') from err
diff --git a/falcon/request.py b/falcon/request.py
index f2eb78d..1489716 100644
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -1031,7 +1031,7 @@ class Request:
                 return default_when_empty
             raise self._media_error
 
-        handler = self.options.media_handlers.find_by_media_type(
+        handler, _, _ = self.options.media_handlers._resolve(
             self.content_type,
             self.options.default_media_type
         )
@@ -1800,7 +1800,7 @@ class Request:
         if param_value is None:
             return default
 
-        handler = self.options.media_handlers.find_by_media_type(
+        handler, _, _ = self.options.media_handlers._resolve(
             MEDIA_JSON, MEDIA_JSON, raise_not_found=False
         )
         if handler is None:
diff --git a/falcon/response.py b/falcon/response.py
index 09a13fc..cf181ef 100644
--- a/falcon/response.py
+++ b/falcon/response.py
@@ -267,7 +267,7 @@ class Response:
                     if not self.content_type:
                         self.content_type = self.options.default_media_type
 
-                    handler = self.options.media_handlers.find_by_media_type(
+                    handler, _, _ = self.options.media_handlers._resolve(
                         self.content_type,
                         self.options.default_media_type
                     )
diff --git a/falcon/util/misc.py b/falcon/util/misc.py
index 30a83ce..89f3e4a 100644
--- a/falcon/util/misc.py
+++ b/falcon/util/misc.py
@@ -33,6 +33,7 @@ import sys
 import unicodedata
 
 from falcon import status_codes
+from falcon.constants import PYPY
 # NOTE(vytas): Hoist `deprecated` here since it is documented as part of the
 # public Falcon interface.
 from .deprecation import deprecated
@@ -71,23 +72,29 @@ utcnow = datetime.datetime.utcnow
 #   workstations, so we use the nocover pragma here.
 def _lru_cache_nop(*args, **kwargs):  # pragma: nocover
     def decorator(func):
+        # NOTE(kgriffs): Partially emulate the lru_cache protocol; only add
+        #   cache_info() later if/when it becomes necessary.
+        func.cache_clear = lambda: None
+
         return func
 
     return decorator
 
 
-if (
-    # NOTE(kgriffs): https://bugs.python.org/issue28969
-    (sys.version_info.minor == 5 and sys.version_info.micro < 4) or
-    (sys.version_info.minor == 6 and sys.version_info.micro < 1) or
-
-    # PERF(kgriffs): Using lru_cache is slower on pypy when the wrapped
-    #   function is just doing a few non-IO operations.
-    (sys.implementation.name == 'pypy')
-):
+# NOTE(kgriffs): https://bugs.python.org/issue28969
+_PYVER_TRIPLET = sys.version_info[:3]
+if _PYVER_TRIPLET >= (3, 5, 4) and _PYVER_TRIPLET != (3, 6, 0):
+    _lru_cache_safe = functools.lru_cache  # type: ignore
+else:
     _lru_cache_safe = _lru_cache_nop  # pragma: nocover
+
+
+# PERF(kgriffs): Using lru_cache is slower on pypy when the wrapped
+#   function is just doing a few non-IO operations.
+if PYPY:
+    _lru_cache_for_simple_logic = _lru_cache_nop  # pragma: nocover
 else:
-    _lru_cache_safe = functools.lru_cache  # type: ignore
+    _lru_cache_for_simple_logic = _lru_cache_safe  # type: ignore
 
 
 def is_python_func(func):
@@ -377,7 +384,7 @@ def secure_filename(filename):
     return _UNSAFE_CHARS.sub('_', filename)
 
 
-@_lru_cache_safe(maxsize=64)
+@_lru_cache_for_simple_logic(maxsize=64)
 def http_status_to_code(status):
     """Normalize an HTTP status to an integer code.
 
@@ -415,7 +422,7 @@ def http_status_to_code(status):
         raise ValueError('status strings must start with a three-digit integer')
 
 
-@_lru_cache_safe(maxsize=64)
+@_lru_cache_for_simple_logic(maxsize=64)
 def code_to_http_status(status):
     """Normalize an HTTP status to an HTTP status line string.
 
diff --git a/falcon/util/uri.py b/falcon/util/uri.py
index 22a6e8d..523b77d 100644
--- a/falcon/util/uri.py
+++ b/falcon/util/uri.py
@@ -24,8 +24,7 @@ in the `falcon` module, and so must be explicitly imported::
 
 """
 
-import platform
-
+from falcon.constants import PYPY
 try:
     from falcon.cyutil.uri import (
         decode as _cy_decode,
@@ -53,8 +52,6 @@ _HEX_TO_BYTE = {(a + b).encode(): bytes([int(a + b, 16)])
                 for a in _HEX_DIGITS
                 for b in _HEX_DIGITS}
 
-_PYPY = platform.python_implementation() == 'PyPy'
-
 
 def _create_char_encoder(allowed_chars):
 
@@ -215,7 +212,7 @@ def _join_tokens_list(tokens):
 #     benefit of being able to decode() off it directly.
 #   * On PyPy, b''.join(list) is the recommended approach, although it may
 #     narrowly lose to BytesIO on the extreme end.
-_join_tokens = _join_tokens_list if _PYPY else _join_tokens_bytearray
+_join_tokens = _join_tokens_list if PYPY else _join_tokens_bytearray
 
 
 def decode(encoded_uri, unquote_plus=True):


Test patch:
diff --git a/tests/asgi/test_response_media_asgi.py b/tests/asgi/test_response_media_asgi.py
index 5923603..88c0816 100644
--- a/tests/asgi/test_response_media_asgi.py
+++ b/tests/asgi/test_response_media_asgi.py
@@ -141,7 +141,8 @@ def test_default_media_type():
     assert result.json == doc
 
 
-def test_mimeparse_edgecases():
+@pytest.mark.parametrize('monkeypatch_resolver', [True, False])
+def test_mimeparse_edgecases(monkeypatch_resolver):
     doc = {'something': True}
 
     class TestResource:
@@ -156,11 +157,29 @@ def test_mimeparse_edgecases():
                 resp.media = {'something': False}
                 await resp.render_body()
 
-            # Clear the content type, shouldn't raise this time
-            resp.content_type = None
-            resp.media = doc
+            # Shouldn't raise
+            for content_type in (None, '*/*'):
+                resp.content_type = content_type
+                resp.media = doc
 
     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_sse.py b/tests/asgi/test_sse.py
index 27b20f2..9719a3f 100644
--- a/tests/asgi/test_sse.py
+++ b/tests/asgi/test_sse.py
@@ -204,7 +204,7 @@ class TestSerializeJson:
 
     def test_use_media_handler_dumps(self, client):
         h = client.app.resp_options.media_handlers[falcon.MEDIA_JSON]
-        h.dumps = lambda x: json.dumps(x).upper()
+        h._dumps = lambda x: json.dumps(x).upper()
 
         result = client.simulate_get()
         assert result.text == (
diff --git a/tests/test_httperror.py b/tests/test_httperror.py
index cf573fb..1ddbb5a 100644
--- a/tests/test_httperror.py
+++ b/tests/test_httperror.py
@@ -881,7 +881,7 @@ class TestHTTPError:
     def test_serialize_error_uses_media_handler(self, client):
         client.app.add_route('/path', NotFoundResource())
         h = client.app.resp_options.media_handlers[falcon.MEDIA_JSON]
-        h.dumps = lambda x: json.dumps(x).upper()
+        h._dumps = lambda x: json.dumps(x).upper()
         response = client.simulate_request(path='/path')
 
         assert response.status == falcon.HTTP_404
diff --git a/tests/test_media_handlers.py b/tests/test_media_handlers.py
index de292e4..27c5dc3 100644
--- a/tests/test_media_handlers.py
+++ b/tests/test_media_handlers.py
@@ -9,6 +9,7 @@ import ujson
 
 import falcon
 from falcon import ASGI_SUPPORTED, media, testing
+from falcon.util.deprecation import DeprecatedWarning
 
 from _util import create_app  # NOQA
 
@@ -110,7 +111,14 @@ def test_deserialization(asgi, func, body, expected):
     assert result == expected
 
 
-def test_deserialization_raises(asgi):
+@pytest.mark.parametrize('monkeypatch_resolver', [True, False])
+@pytest.mark.parametrize('handler_mt', [
+    'application/json',
+
+    # NOTE(kgriffs): Include a bogus parameter to validate fuzzy matching logic
+    'application/json; answer=42'
+])
+def test_deserialization_raises(asgi, handler_mt, monkeypatch_resolver):
     app = create_app(asgi)
 
     class SuchException(Exception):
@@ -126,7 +134,22 @@ def test_deserialization_raises(asgi):
         def serialize(self, media, content_type):
             raise SuchException('Wow such error.')
 
-    handlers = media.Handlers({'application/json': FaultyHandler()})
+    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
 
@@ -252,10 +275,26 @@ def test_async_handler_returning_none():
     assert result.json == [None]
 
 
-def test_json_err_no_handler(asgi):
+@pytest.mark.parametrize('monkeypatch_resolver', [True, False])
+def test_json_err_no_handler(asgi, monkeypatch_resolver):
     app = 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_media_urlencoded.py b/tests/test_media_urlencoded.py
index aa194c4..19e8f31 100644
--- a/tests/test_media_urlencoded.py
+++ b/tests/test_media_urlencoded.py
@@ -24,8 +24,8 @@ def test_deserialize_invalid_unicode():
 
 
 @pytest.mark.parametrize('data,expected', [
-    ({'hello': 'world'}, 'hello=world'),
-    ({'number': [1, 2]}, 'number=1&number=2'),
+    ({'hello': 'world'}, b'hello=world'),
+    ({'number': [1, 2]}, b'number=1&number=2'),
 ])
 def test_urlencoded_form_handler_serialize(data, expected):
     handler = media.URLEncodedFormHandler()
diff --git a/tests/test_query_params.py b/tests/test_query_params.py
index 9a4def2..67f78fa 100644
--- a/tests/test_query_params.py
+++ b/tests/test_query_params.py
@@ -899,7 +899,7 @@ class TestQueryParams:
         client.app.add_route('/', resource)
         payload_dict = {'foo': 'bar'}
         query_string = 'payload={}'.format(json.dumps(payload_dict))
-        client.app.req_options.media_handlers[falcon.MEDIA_JSON].loads = lambda x: {'x': 'y'}
+        client.app.req_options.media_handlers[falcon.MEDIA_JSON]._loads = lambda x: {'x': 'y'}
         client.simulate_get(path='/', query_string=query_string)
         req = resource.captured_req
         assert req.get_param_as_json('payload') == {'x': 'y'}
diff --git a/tests/test_response_body.py b/tests/test_response_body.py
index 6291b75..13b455a 100644
--- a/tests/test_response_body.py
+++ b/tests/test_response_body.py
@@ -120,3 +120,8 @@ def test_response_body_rendition_error(asgi):
 
     resp = testing.simulate_get(app, '/test.mal')
     assert resp.status_code == 753
+
+    # NOTE(kgriffs): Validate that del works on media handlers.
+    del app.resp_options.media_handlers['text/x-malbolge']
+    resp = testing.simulate_get(app, '/test.mal')
+    assert resp.status_code == 415
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 539f7ea..3eccb34 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -15,7 +15,12 @@ import falcon
 from falcon import media
 from falcon import testing
 from falcon import util
-from falcon.constants import MEDIA_JSON
+from falcon.constants import (
+    MEDIA_JSON,
+    MEDIA_MSGPACK,
+    MEDIA_URLENCODED,
+    MEDIA_YAML
+)
 from falcon.util import deprecation, misc, structures, uri
 
 from _util import create_app, to_coroutine  # NOQA
@@ -44,6 +49,54 @@ def decode_approach(request, monkeypatch):
     return method
 
 
+class TrackingJSONHandler(media.JSONHandler):
+    def __init__(self):
+        super().__init__()
+        self.deserialize_count = 0
+
+    def deserialize(self, *args, **kwargs):
+        result = super().deserialize(*args, **kwargs)
+        self.deserialize_count += 1
+        return result
+
+    async def deserialize_async(self, *args, **kwargs):
+        result = await super().deserialize_async(*args, **kwargs)
+        self.deserialize_count += 1
+        return result
+
+
+class TrackingMessagePackHandler(media.MessagePackHandler):
+    def __init__(self):
+        super().__init__()
+        self.deserialize_count = 0
+
+    def deserialize(self, *args, **kwargs):
+        result = super().deserialize(*args, **kwargs)
+        self.deserialize_count += 1
+        return result
+
+    async def deserialize_async(self, *args, **kwargs):
+        result = await super().deserialize_async(*args, **kwargs)
+        self.deserialize_count += 1
+        return result
+
+
+class TrackingFormHandler(media.URLEncodedFormHandler):
+    def __init__(self):
+        super().__init__()
+        self.deserialize_count = 0
+
+    def deserialize(self, *args, **kwargs):
+        result = super().deserialize(*args, **kwargs)
+        self.deserialize_count += 1
+        return result
+
+    async def deserialize_async(self, *args, **kwargs):
+        result = await super().deserialize_async(*args, **kwargs)
+        self.deserialize_count += 1
+        return result
+
+
 class TestFalconUtils:
 
     def setup_method(self, method):
@@ -905,32 +958,49 @@ class TestFalconTestingUtils:
             assert resp.status_code == 415
 
     @pytest.mark.parametrize('content_type', [
-        'application/json',
-        'application/json; charset=UTF-8',
-        'application/yaml'
+        MEDIA_JSON,
+        MEDIA_JSON + '; charset=UTF-8',
+        MEDIA_YAML,
+        MEDIA_MSGPACK,
+        MEDIA_URLENCODED,
     ])
     def test_simulate_content_type_extra_handler(self, asgi, content_type):
-        class TrackingJSONHandler(media.JSONHandler):
+        class TestResourceAsync(testing.SimpleTestResourceAsync):
             def __init__(self):
                 super().__init__()
-                self.deserialize_count = 0
 
-            def deserialize(self, *args, **kwargs):
-                result = super().deserialize(*args, **kwargs)
-                self.deserialize_count += 1
-                return result
+            async def on_post(self, req, resp):
+                await super().on_post(req, resp)
 
-            async def deserialize_async(self, *args, **kwargs):
-                result = await super().deserialize_async(*args, **kwargs)
-                self.deserialize_count += 1
-                return result
+                resp.media = {'hello': 'back'}
+                resp.content_type = content_type
 
-        resource = testing.SimpleTestResourceAsync() if asgi else testing.SimpleTestResource()
+        class TestResource(testing.SimpleTestResource):
+            def __init__(self):
+                super().__init__()
+
+            def on_post(self, req, resp):
+                super().on_post(req, resp)
+
+                resp.media = {'hello': 'back'}
+                resp.content_type = content_type
+
+        resource = TestResourceAsync() if asgi else TestResource()
         app = create_app(asgi)
         app.add_route('/', resource)
 
-        handler = TrackingJSONHandler()
-        extra_handlers = {'application/json': handler}
+        json_handler = TrackingJSONHandler()
+        msgpack_handler = TrackingMessagePackHandler()
+        form_handler = TrackingFormHandler()
+
+        # NOTE(kgriffs): Do not use MEDIA_* so that we can sanity-check that
+        #   our constants that are used in the pytest parametrization match
+        #   up to what we expect them to be.
+        extra_handlers = {
+            'application/json': json_handler,
+            'application/msgpack': msgpack_handler,
+            'application/x-www-form-urlencoded': form_handler,
+        }
         app.req_options.media_handlers.update(extra_handlers)
         app.resp_options.media_handlers.update(extra_handlers)
 
@@ -939,17 +1009,59 @@ class TestFalconTestingUtils:
             'Content-Type': content_type,
             'capture-req-media': 'y',
         }
-        payload = b'{"hello": "world"}'
+
+        if MEDIA_JSON in content_type:
+            payload = b'{"hello": "world"}'
+        elif content_type == MEDIA_MSGPACK:
+            payload = b'\x81\xa5hello\xa5world'
+        elif content_type == MEDIA_URLENCODED:
+            payload = b'hello=world'
+        else:
+            payload = None
+
         resp = client.simulate_post('/', headers=headers, body=payload)
 
         if MEDIA_JSON in content_type:
+            assert resp.status_code == 200
+            assert resp.json == {'hello': 'back'}
+
+            # Test that our custom deserializer was called
+            assert json_handler.deserialize_count == 1
+            assert resource.captured_req_media == {'hello': 'world'}
+
+            # Verify that other handlers were not called
+            assert msgpack_handler.deserialize_count == 0
+            assert form_handler.deserialize_count == 0
+
+        elif content_type == MEDIA_MSGPACK:
+            assert resp.status_code == 200
+            assert resp.content == b'\x81\xa5hello\xa4back'
+
             # Test that our custom deserializer was called
-            assert handler.deserialize_count == 1
+            assert msgpack_handler.deserialize_count == 1
             assert resource.captured_req_media == {'hello': 'world'}
+
+            # Verify that other handlers were not called
+            assert json_handler.deserialize_count == 0
+            assert form_handler.deserialize_count == 0
+
+        elif content_type == MEDIA_URLENCODED:
             assert resp.status_code == 200
+            assert resp.content == b'hello=back'
+
+            # Test that our custom deserializer was called
+            assert form_handler.deserialize_count == 1
+            assert resource.captured_req_media == {'hello': 'world'}
+
+            # Verify that other handlers were not called
+            assert json_handler.deserialize_count == 0
+            assert msgpack_handler.deserialize_count == 0
+
         else:
             # YAML should not get handled
-            assert handler.deserialize_count == 0
+            for handler in (json_handler, msgpack_handler):
+                assert handler.deserialize_count == 0
+
             assert resource.captured_req_media is None
             assert resp.status_code == 415