Repository: falconry/falcon

Issue:
Media handlers do not get a chance to deserialize zero-length payload
At the time of writing, `Request.media` property implementation starts this way:
```python
if self._media is not None or self.bounded_stream.is_exhausted:
    return self._media
```

If media in question is serialized to an empty (byte)string, i.e. the request `Content-Length` is zero, the `bounded_stream` is automatically considered exhausted. Hence `Request.media` automatically reduces to `None` without even resolving media handlers for the submitted content type.

While this is the correct behaviour for certain types of media, such as YAML, and for many other media types such as JSON, Msgpack, most audio/video/picture types etc it looks like an acceptable (albeit debatable since deserialization of empty input for these is undefined or defined to be malformed -- see also the discussion in https://github.com/falconry/falcon/issues/1216 ) workaround, there might be cases where an empty string could be expected to be deserialized to another value, such as in the `application/x-www-form-urlencoded` case:
```python
>>> import urllib.parse
>>> urllib.parse.parse_qs(b'')
{}
```

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1758?src=pr&el=h1) Report
> Merging [#1758](https://codecov.io/gh/falconry/falcon/pull/1758?src=pr&el=desc) into [master](https://codecov.io/gh/falconry/falcon/commit/d946b4f0acf6c9fd25015f517499880c19533932&el=desc) will **decrease** coverage by `8.87%`.
> The diff coverage is `100.00%`.

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

```diff
@@             Coverage Diff             @@
##            master    #1758      +/-   ##
===========================================
- Coverage   100.00%   91.12%   -8.88%     
===========================================
  Files           52       46       -6     
  Lines         4291     3324     -967     
  Branches       689      507     -182     
===========================================
- Hits          4291     3029    -1262     
- Misses           0      277     +277     
- Partials         0       18      +18     
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1758?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/testing/resource.py](https://codecov.io/gh/falconry/falcon/pull/1758/diff?src=pr&el=tree#diff-ZmFsY29uL3Rlc3RpbmcvcmVzb3VyY2UucHk=) | `81.35% <100.00%> (-18.65%)` | :arrow_down: |
| [falcon/asgi/response.py](https://codecov.io/gh/falconry/falcon/pull/1758/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvcmVzcG9uc2UucHk=) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [falcon/asgi/\_request\_helpers.py](https://codecov.io/gh/falconry/falcon/pull/1758/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvX3JlcXVlc3RfaGVscGVycy5weQ==) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [falcon/responders.py](https://codecov.io/gh/falconry/falcon/pull/1758/diff?src=pr&el=tree#diff-ZmFsY29uL3Jlc3BvbmRlcnMucHk=) | `64.70% <0.00%> (-35.30%)` | :arrow_down: |
| [falcon/media/json.py](https://codecov.io/gh/falconry/falcon/pull/1758/diff?src=pr&el=tree#diff-ZmFsY29uL21lZGlhL2pzb24ucHk=) | `68.96% <0.00%> (-31.04%)` | :arrow_down: |
| [falcon/media/validators/jsonschema.py](https://codecov.io/gh/falconry/falcon/pull/1758/diff?src=pr&el=tree#diff-ZmFsY29uL21lZGlhL3ZhbGlkYXRvcnMvanNvbnNjaGVtYS5weQ==) | `69.04% <0.00%> (-30.96%)` | :arrow_down: |
| [falcon/testing/helpers.py](https://codecov.io/gh/falconry/falcon/pull/1758/diff?src=pr&el=tree#diff-ZmFsY29uL3Rlc3RpbmcvaGVscGVycy5weQ==) | `70.96% <0.00%> (-29.04%)` | :arrow_down: |
| [falcon/util/sync.py](https://codecov.io/gh/falconry/falcon/pull/1758/diff?src=pr&el=tree#diff-ZmFsY29uL3V0aWwvc3luYy5weQ==) | `73.17% <0.00%> (-26.83%)` | :arrow_down: |
| [falcon/media/msgpack.py](https://codecov.io/gh/falconry/falcon/pull/1758/diff?src=pr&el=tree#diff-ZmFsY29uL21lZGlhL21zZ3BhY2sucHk=) | `73.91% <0.00%> (-26.09%)` | :arrow_down: |
| [falcon/cmd/inspect\_app.py](https://codecov.io/gh/falconry/falcon/pull/1758/diff?src=pr&el=tree#diff-ZmFsY29uL2NtZC9pbnNwZWN0X2FwcC5weQ==) | `79.54% <0.00%> (-20.46%)` | :arrow_down: |
| ... and [22 more](https://codecov.io/gh/falconry/falcon/pull/1758/diff?src=pr&el=tree-more) | |

------

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

CaselIT: > As implemented is probably the cleanest version, but I think we may scale it back a bit by:
> 
>    * if the media handler returns an error, the stream was empty and the user has not supplied a fallback value, warn the user that next version will propagate the exception
> 
>    * return `None`

The problem with this is that there is no way of not getting the warning if one prefers the new behavior.
CaselIT: Thanks for the review.

> Your fallback machinery is undeniably neat, but for existing users that would be almost as abrupt breaking as adding try... excepts everywhere.

That was my thinking as well, as is now it's probably the cleanest behaviour but it is possibly a very large change. All in all, I'm not sure I would be in favour of merging it as is.

> 
> 
> > As implemented is probably the cleanest version, but I think we may scale it back a bit by:
> > 
> > * if the media handler returns an error, the stream was empty and the user has not supplied a fallback value, warn the user that next version will propagate the exception
> > * return `None`
> 
> The problem with this is that there is no way of not getting the warning if one prefers the new behavior.

Thinking a bit more on this, I think we need to provide a constant that an user may use to opt for the new behaviour, something like `MEDIA_PROPAGATE_EMPTY_EXCEPTION` (possibly shorter), so that the warning may be silenced opting for the new behaviour.
Then in falcon 4 we may set this value by default in `get_media`.
Enum are not used by falcon atm (I think) but maybe one could be used for this? Not sure
vytas7: A side note re `enum`s. No, they are not used by Falcon per se yet, but we have now added support for setting `resp.status` to an instance of [`http.HTTPStatus`](https://docs.python.org/3/library/http.html#http.HTTPStatus).
CaselIT: Note: this should maybe wait for https://github.com/falconry/falcon/pull/1741 since it changes handlers a bit
kgriffs: > I think we need to provide a constant that an user may use to opt for the new behaviour

Just as a reminder, don't forget to create an issue and assign it to the appropriate milestone so this work doesn't fall through the cracks.

> Note: this should maybe wait for #1741 since it changes handlers a bit

Will you have some time to circle back on this PR over the next few days? Thanks!
CaselIT: > Will you have some time to circle back on this PR over the next few days? Thanks!

Sure, I can complete this now that we have also the websocket done.

But I'm not sure if passing a value to `get_media` is the best way. I mean it makes sense for json, but as @vytas7 pointed out, it may not be great for other media handler, like the multiplar one

I guess the alternative is to have something on the handler, like a method we can call or a constant (but the method is more flexible) or have an option like the above suggested `MEDIA_PROPAGATE_EMPTY_EXCEPTION ` suggested above 
kgriffs: TBH I think we need to also update the media handlers themselves to afford a more specific error handling protocol. Otherwise, catching any `Exception` type could potentially mask some issues from the app developer, making said issues difficult to diagnose.

I also think we should bubble up more context to the app developer re what actually happened, allowing them to choose their own adventure as needed. The more affordances the better.

With that in mind, I would propose something along these lines:

```py
def deserialize(self, stream, content_type, content_length):
    data = stream.read()

    # BREAKING: Previously we would raise json.JSONDecodeError in this case;
    #   we should probably subclass ValueError since json.JSONDecodeError also
    #   subclasses the same.
    #
    #   We might also want to install a default error handler in the app
    #   instance for this type. Perhaps it could just raise HTTPBadRequest.
    if not data:
        raise MediaNotFoundError()

    try:
        return self.loads(data.decode())
    except ValueError as ex:
        # Could be either json.JSONDecodeError or UnicodeDecodeError, since
        #   both subclass ValueError.

        # BREAKING: Previously we would just raise errors.HTTPBadRequest in
        #   this case. To mitigate the impact of this change, a default error
        #   handler can be added to the App instance that will raise
        #   an instance of HTTPBadRequest.
        raise MediaMalformedError(ex)  # set inner exception
```

The `get_media()` method would not catch these exceptions by default, but let them bubble up to the caller. This is similar to what happens already. We could still accept a kwarg to specify a default value (more on this a bit further down below).

We would note in the docs that custom media handlers should normally follow this pattern (especially if they are releasing it in a library for third-party consumption), although they might choose to return a default value in lieu of raising an instance of MediaNotFoundError.

In terms of customizing the default behavior, the framework provides at least four affordances: (a) implement your own custom media handler, (b) catch the exception in the responder, and (c) add custom exception handlers to the App instance, (d) implement a custom Request subclass that overrides `get_media()`. It might be useful to explicitly note these options in the media handling docs.

(b) could look something like this:

```py
try:
    resource_representation = self.media

except MediaNotFoundError:  # Empty request body
    # Depending on the interface specification, the app might just opt for a
    # default value here, or return a 400, or something else.
    resource_representation = {}

except MediaMalformedError as ex: # Bad syntax or incomplete body
    ex.inner  # Can be referenced to determine the root cause
    pass

except Exception as ex:
    # Some other weird thing might have happend; deal with it here
    # or omit this except block and let a registered error handler
    # deal with it.
    pass
```

We could also keep the kwarg for `get_media()` for returning a default value when `MediaNotFoundError` or `MediaMalformedError` is raised (other exceptions would not be caught). This kind of makes sense in the context of what we afford for headers and what the stdlib affords for `dict`. Anyway, this is essentially a new feature and people could switch from using the `req.media` to opt into using the feature if they find it useful. The default value might very well need to be different depending on the API design and the resource being manipulated. For now we default to just raising the errors since that is similar to what we already do, and if people want a default value they can opt in as just suggested.

In addition, we might accept a kwarg in the media handler initializer for setting a universal default that the handler would return in lieu or raising an error, but I'm not yet convinced that is better than `try...except` in the responders.

On the other hand, for multipart a default value doesn't make a lot of sense. If someone wants to specify one as a kwarg to `get_media()` I guess that's their choice and there isn't any reason for the framework to explicitly disallow this pattern. But we shouldn't do anything in the design that requires all media handlers to return a default value instead of raising an error
vytas7: That's a great summary @kgriffs.

Adding to this, as I wrote on the issue, I would also suggest adding the default value fallback as an initializer option to certain handlers we ship, namely `JSONHandler` (and probably `MessagePackHandler`).
This way, people could easily customize their APIs to match 2.0 behaviour.
vytas7: Tbh repeated access to `req.media` is problematic regardless, so maybe we should be explicitly discouraging this pattern?

If a responder exploded with a `MediaMalformedError`, or chose to suppress it, for middleware processing this response, it would look as either some default value, or a `MediaNotFoundError` would be raised, since the request stream could have been exhausted at this point.

But then we should probably be suggesting alternatives to this pattern in our FAQ.
vytas7: @kgriffs just a clarification :arrow_up_down: 
```python
    # BREAKING: Previously we would raise json.JSONDecodeError in this case;
    #   we should probably subclass ValueError since json.JSONDecodeError also
    #   subclasses the same.
    #
    #   We might also want to install a default error handler in the app
    #   instance for this type. Perhaps it could just raise HTTPBadRequest.
    if not data:
        raise MediaNotFoundError()
```
While this is true for an isolated instance of `JSONHandler`, the current 2.0 behaviour is that [`req.media` simply returns `None`](https://github.com/falconry/falcon/blob/2.0/falcon/request.py#L961).
CaselIT: Thanks for the summary.

I think that it makes sense. We should probably have a section in the media docs that explains the rationale and how custom handlers are best implemented.

I'll try to update this a bit to see if we can move it in the right direction. (I think I'll first update the implementation and ask for feedback, then once we have nailed it update the testing)

Regarding the exceptions, should they be subclasses of HTTPBadRequest?
If not, I think we should provide a default handler for them that sends a 400 http error, otherwise by default the generic Exception handler would be called resulting in a 500 error that may be unexpected to most users.
vytas7: > Regarding the exceptions, should they be subclasses of HTTPBadRequest?
If not, I think we should provide a default handler for them that sends a 400 http error, otherwise by default the generic Exception handler would be called resulting in a 500 error that may be unexpected to most users.

Agreed @CaselIT .

If I'm just using the basic media functionality like `req.get_media()`, I expect the framework to return an [`HTTP 400`](https://tools.ietf.org/html/rfc7231#section-6.5.1) to the client out of the box, provided the handler in question is conforming to our protocol (which the standard `JSONHandler` obviously must do).
CaselIT: One thing to decide if is if the websockets handlers should follow the same behaviour
CaselIT: I've updated the implementation to see how it could look like.

I've gone for the route of having the media errors be subclasses of `HTTPBadRequest`. In the end we have tree errors:
- a generic `MediaError` subclass of `HTTPBadRequest`
- `MediaMalformedError` subclass of `MediaError`
- `MediaNotFoundError` subclass of `MediaError`

The generic `MediaError` is raised by the `Request` class if any of error was raised while de-serializing the request the first time. This is to avoid ambiguous behaviors, like raising a `MediaMalformedError` the first time and then `MediaNotFoundError` on following accesses.

Could you check if this implementation may make sense? It's not ready to be merged yet, since it's missing tests and docs.
kgriffs: > One thing to decide is if the websockets handlers should follow the same behaviour

Let's address this post 3.0
kgriffs: > The generic `MediaError` is raised by the `Request` class if any of error was raised while de-serializing the request the first time. This is to avoid ambiguous behaviors, like raising a `MediaMalformedError` the first time and then `MediaNotFoundError` on following accesses.

Good catch. This is a subtle, yet important issue.

I've been thinking about this, and IMO the ideal developer experience is probably to consistently get `MediaMalformedError` or `MediaNotFoundError` both the first time and the Nth time. To me this is conceptually more consistent with the idea that we "cache" the result of parsing the body into a a media result, whether it be a successful result or an error result. Furthermore, raising a generic error is less transparent and may make things that much harder to debug.
CaselIT: > 
> 
> > The generic `MediaError` is raised by the `Request` class if any of error was raised while de-serializing the request the first time. This is to avoid ambiguous behaviors, like raising a `MediaMalformedError` the first time and then `MediaNotFoundError` on following accesses.
> 
> Good catch. This is a subtle, yet important issue.
> 
> I've been thinking about this, and IMO the ideal developer experience is probably to consistently get `MediaMalformedError` or `MediaNotFoundError` both the first time and the Nth time. To me this is conceptually more consistent with the idea that we "cache" the result of parsing the body into a a media result, whether it be a successful result or an error result. Furthermore, raising a generic error is less transparent and may make things that much harder to debug.

I also had the same thought, and I agree that it would be best. The only problem with it is maybe that we need to keep a reference of the exception until the request get gced, but I think it would not cause too much hassle, since it should be a fairly short time in any case.

I'll do that and remove the generic `MediaError`.
CaselIT: @vytas7 @kgriffs I've updated. It should be ready for review.

The only thing to decide if we should somehow raise a `MediaNotFoundError` in the multipart handler
CaselIT: ready for another round
vytas7: @CaselIT let me take a look at the multipart stuff tomorrow.

It shouldn't be too hard to raise that specialized error in the parsing process.
Not sure, however, if it brings much benefit in the multipart case. If the supplied `Content-Type` provides a valid `boundary` parameter, it could be treated as part of the form, i.e., at least it shouldn't be sent at random together witth `GET` requests.
But it probably still has value for some generalized middleware (?)
kgriffs: @vytas7 What do you think we need to do with multipart for 3.0 vs. what we can push to a later release? 
vytas7: On consideration, it's probably fine to leave it as-is.

Providing `Content-Type` set to `multipart/form-data` with a valid boundary is enough to construct and return the iterator over parts... So maybe we could do without that specialized empty body error for now (3.0).
CaselIT: Yes I noticed, I'll test it explicitly
CaselIT: I think it;s ready for final review
CaselIT: Updated

Patch:
diff --git a/docs/_newsfragments/1589.breakingchange.rst b/docs/_newsfragments/1589.breakingchange.rst
new file mode 100644
index 0000000..56ebf87
--- /dev/null
+++ b/docs/_newsfragments/1589.breakingchange.rst
@@ -0,0 +1,18 @@
+General refactoring of internal media handler:
+
+*  Deserializing an empty body with a handler that does not support it will
+   raise :class:`falcon.MediaNotFoundError`, and will be rendered as a
+   ``400 Bad Request`` response. This error may be suppressed by passing
+   a default value to ``get_media`` to be used in case of empty body.
+   See also :meth:`Request.get_media` for details.
+   Previously ``None`` was returned in all cases without calling the handler.
+*  Exceptions raised by the handlers are wrapped as
+   :class:`falcon.MediaMalformedError`, and will be rendered as a
+   ``400 Bad Request`` response.
+*  Subsequent calls to :meth:`Request.get_media` or :attr:`Request.media` will
+   re-raise the same exception, if the first call ended in an error, unless the
+   exception was a :class:`falcon.MediaNotFoundError` and a default value is
+   passed to the ``default_when_empty`` attribute of the current invocation.
+   Previously ``None`` was returned.
+
+External handlers should update their logic to align to the internal Falcon handlers.
diff --git a/docs/api/errors.rst b/docs/api/errors.rst
index 0ec855d..f79e5a8 100644
--- a/docs/api/errors.rst
+++ b/docs/api/errors.rst
@@ -211,3 +211,9 @@ Predefined Errors
 
 .. autoclass:: falcon.HTTPNetworkAuthenticationRequired
     :members:
+
+.. autoclass:: falcon.MediaNotFoundError
+    :members:
+
+.. autoclass:: falcon.MediaMalformedError
+    :members:
diff --git a/docs/api/media.rst b/docs/api/media.rst
index d04f59e..1564cee 100644
--- a/docs/api/media.rst
+++ b/docs/api/media.rst
@@ -128,6 +128,30 @@ middleware. Here is an example of how this can be done:
                     resp.content_type = req.accept
 
 
+Exception Handling
+------------------
+
+Version 3 of Falcon updated how the handling of exceptions raised by handlers behaves:
+
+*  Falcon lets the media handler try to deserialized an empty body. For the media types
+   that don't allow empty bodies as a valid value, such as ``JSON``, an instance of
+   :class:`falcon.MediaNotFoundError` should be raised. By default, this error
+   will be rendered as a ``400 Bad Request`` response to the client.
+   This exception may be suppressed by passing a value to the ``default_when_empty``
+   argument when calling :meth:`Request.get_media`. In this case, this value will
+   be returned by the call.
+*  If a handler encounters an error while parsing a non-empty body, an instance of
+   :class:`falcon.MediaMalformedError` should be raised. The original exception, if any,
+   is stored in the ``__cause__`` attribute of the raised instance. By default, this
+   error will be rendered as a ``400 Bad Request`` response to the client.
+
+If any exception was raised by the handler while parsing the body, all subsequent invocations
+of :meth:`Request.get_media` or :attr:`Request.media` will result in a re-raise of the same
+exception, unless the exception was a :class:`falcon.MediaNotFoundError` and a default value
+is passed to the ``default_when_empty`` attribute of the current invocation.
+
+External handlers should update their logic to align to the internal Falcon handlers.
+
 .. _custom_media_handlers:
 
 Replacing the Default Handlers
diff --git a/falcon/asgi/multipart.py b/falcon/asgi/multipart.py
index 2c66a82..6253907 100644
--- a/falcon/asgi/multipart.py
+++ b/falcon/asgi/multipart.py
@@ -60,9 +60,10 @@ class BodyPart(multipart.BodyPart):
         charset = options.get('charset', self._parse_options.default_charset)
         try:
             return (await self.get_data()).decode(charset)
-        except (ValueError, LookupError):
+        except (ValueError, LookupError) as err:
             raise MultipartParseError(
-                description='invalid text or charset: {}'.format(charset))
+                description='invalid text or charset: {}'.format(charset)
+            ) from err
 
     data = property(get_data)
     media = property(get_media)
@@ -112,20 +113,17 @@ class MultipartForm:
                     # end of a multipart form.
                     break
                 elif separator:
-                    raise MultipartParseError(
-                        description='unexpected form structure')
+                    raise MultipartParseError(description='unexpected form structure')
 
-            except DelimiterError:
-                raise MultipartParseError(
-                    description='unexpected form structure')
+            except DelimiterError as err:
+                raise MultipartParseError(description='unexpected form structure') from err
 
             headers = {}
             try:
                 headers_block = await stream.read_until(
                     _CRLF_CRLF, max_headers_size, consume_delimiter=True)
-            except DelimiterError:
-                raise MultipartParseError(
-                    description='incomplete body part headers')
+            except DelimiterError as err:
+                raise MultipartParseError(description='incomplete body part headers') from err
 
             for line in headers_block.split(_CRLF):
                 name, sep, value = line.partition(b': ')
@@ -142,9 +140,9 @@ class MultipartForm:
                     #   bodies have been discovered.
                     if name == b'content-transfer-encoding' and value != b'binary':
                         raise MultipartParseError(
-                            description=(
-                                'the deprecated Content-Transfer-Encoding '
-                                'header field is unsupported'))
+                            description=('the deprecated Content-Transfer-Encoding '
+                                         'header field is unsupported')
+                        )
                     # NOTE(vytas): RFC 7578, section 4.8.
                     #   Other header fields MUST NOT be included and MUST be
                     #   ignored.
@@ -154,7 +152,8 @@ class MultipartForm:
             remaining_parts -= 1
             if remaining_parts < 0 < self._parse_options.max_body_part_count:
                 raise MultipartParseError(
-                    description='maximum number of form body parts exceeded')
+                    description='maximum number of form body parts exceeded'
+                )
 
             yield BodyPart(stream.delimit(delimiter), headers,
                            self._parse_options)
diff --git a/falcon/asgi/request.py b/falcon/asgi/request.py
index e798af0..af1ed39 100644
--- a/falcon/asgi/request.py
+++ b/falcon/asgi/request.py
@@ -27,6 +27,8 @@ from .stream import BoundedStream
 
 __all__ = ['Request']
 
+_UNSET = falcon.request._UNSET
+
 
 class Request(falcon.request.Request):
     """Represents a client's HTTP request.
@@ -390,7 +392,8 @@ class Request(falcon.request.Request):
         self.method = 'GET' if self.is_websocket else scope['method']
 
         self.uri_template = None
-        self._media = None
+        self._media = _UNSET
+        self._media_error = None
 
         # TODO(kgriffs): ASGI does not specify whether 'path' may be empty,
         #   as was allowed for WSGI.
@@ -696,7 +699,7 @@ class Request(falcon.request.Request):
 
         return netloc_value
 
-    async def get_media(self):
+    async def get_media(self, default_when_empty=_UNSET):
         """Return a deserialized form of the request stream.
 
         The first time this method is called, the request stream will be
@@ -712,17 +715,40 @@ class Request(falcon.request.Request):
 
         See also :ref:`media` for more information regarding media handling.
 
+        Note:
+            When ``get_media`` is called on a request with an empty body,
+            Falcon will let the media handler try to deserialize the body
+            and will return the value returned by the handler or propagate
+            the exception raised by it. To instead return a different value
+            in case of an exception by the handler, specify the argument
+            ``default_when_empty``.
+
         Warning:
             This operation will consume the request stream the first time
             it's called and cache the results. Follow-up calls will just
             retrieve a cached version of the object.
 
+        Args:
+            default_when_empty: Fallback value to return when there is no body
+                in the request and the media handler raises an error
+                (like in the case of the default JSON media handler).
+                By default, Falcon uses the value returned by the media handler
+                or propagates the raised exception, if any.
+                This value is not cached, and will be used only for the current
+                call.
+
         Returns:
             media (object): The deserialized media representation.
         """
 
-        if self._media is not None or self.stream.eof:
+        if self._media is not _UNSET:
             return self._media
+        if self._media_error is not None:
+            if default_when_empty is not _UNSET and isinstance(
+                self._media_error, errors.MediaNotFoundError
+            ):
+                return default_when_empty
+            raise self._media_error
 
         handler = self.options.media_handlers.find_by_media_type(
             self.content_type,
@@ -735,6 +761,14 @@ class Request(falcon.request.Request):
                 self.content_type,
                 self.content_length
             )
+        except errors.MediaNotFoundError as err:
+            self._media_error = err
+            if default_when_empty is not _UNSET:
+                return default_when_empty
+            raise
+        except Exception as err:
+            self._media_error = err
+            raise
         finally:
             if handler.exhaust_stream:
                 await self.stream.exhaust()
diff --git a/falcon/errors.py b/falcon/errors.py
index 26fda8d..95029eb 100644
--- a/falcon/errors.py
+++ b/falcon/errors.py
@@ -2288,6 +2288,108 @@ class HTTPMissingParam(HTTPBadRequest):
             **kwargs
         )
 
+
+class MediaNotFoundError(HTTPBadRequest):
+    """400 Bad Request.
+
+    Exception raised by a media handler when trying to parse an empty body.
+
+    Note:
+        Some media handlers, like the one for URL-encoded forms, allow an
+        empty body. In these cases this exception will not be raised.
+
+    Args:
+        media_type (str): The media type that was expected.
+
+    Keyword Args:
+        headers (dict or list): A ``dict`` of header names and values
+            to set, or a ``list`` of (*name*, *value*) tuples. Both *name* and
+            *value* must be of type ``str`` or ``StringType``, and only
+            character values 0x00 through 0xFF may be used on platforms that
+            use wide characters.
+
+            Note:
+                The Content-Type header, if present, will be overridden. If
+                you wish to return custom error messages, you can create
+                your own HTTP error class, and install an error handler
+                to convert it into an appropriate HTTP response for the
+                client
+
+            Note:
+                Falcon can process a list of ``tuple`` slightly faster
+                than a ``dict``.
+        href (str): A URL someone can visit to find out more information
+            (default ``None``). Unicode characters are percent-encoded.
+        href_text (str): If href is given, use this as the friendly
+            title/description for the link (default 'API documentation
+            for this error').
+        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``).
+    """
+
+    def __init__(self, media_type, **kwargs):
+        super().__init__(
+            title='Invalid {0}'.format(media_type),
+            description='Could not parse an empty {0} body'.format(media_type),
+            **kwargs
+        )
+
+
+class MediaMalformedError(HTTPBadRequest):
+    """400 Bad Request.
+
+    Exception raised by a media handler when trying to parse a malformed body.
+    The cause of this exception, if any, is stored in the ``__cause__`` attribute
+    using the "raise ... from" form when raising.
+
+    Args:
+        media_type (str): The media type that was expected.
+
+    Keyword Args:
+        headers (dict or list): A ``dict`` of header names and values
+            to set, or a ``list`` of (*name*, *value*) tuples. Both *name* and
+            *value* must be of type ``str`` or ``StringType``, and only
+            character values 0x00 through 0xFF may be used on platforms that
+            use wide characters.
+
+            Note:
+                The Content-Type header, if present, will be overridden. If
+                you wish to return custom error messages, you can create
+                your own HTTP error class, and install an error handler
+                to convert it into an appropriate HTTP response for the
+                client
+
+            Note:
+                Falcon can process a list of ``tuple`` slightly faster
+                than a ``dict``.
+        href (str): A URL someone can visit to find out more information
+            (default ``None``). Unicode characters are percent-encoded.
+        href_text (str): If href is given, use this as the friendly
+            title/description for the link (default 'API documentation
+            for this error').
+        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``).
+    """
+
+    def __init__(self, media_type, **kwargs):
+        super().__init__(
+            title='Invalid {0}'.format(media_type), description=None, **kwargs
+        )
+        self._media_type = media_type
+
+    @property
+    def description(self):
+        msg = 'Could not parse {} body'.format(self._media_type)
+        if self.__cause__ is not None:
+            msg += ' - {}'.format(self.__cause__)
+        return msg
+
+    @description.setter
+    def description(self, value):
+        pass
+
 # -----------------------------------------------------------------------------
 # Helpers
 # -----------------------------------------------------------------------------
diff --git a/falcon/media/base.py b/falcon/media/base.py
index debc50c..3258a61 100644
--- a/falcon/media/base.py
+++ b/falcon/media/base.py
@@ -132,7 +132,7 @@ class BaseHandler(metaclass=abc.ABCMeta):
         return self.deserialize(io.BytesIO(data), content_type, content_length)
 
     exhaust_stream = False
-    """Whether to exhaust the WSGI input stream upon finishing deserialization.
+    """Whether to exhaust the input stream upon finishing deserialization.
 
     Exhausting the stream may be useful for handlers that do not necessarily
     consume the whole stream, but the deserialized media object is complete and
diff --git a/falcon/media/json.py b/falcon/media/json.py
index 3310132..b2c55b6 100644
--- a/falcon/media/json.py
+++ b/falcon/media/json.py
@@ -16,6 +16,10 @@ class JSONHandler(BaseHandler):
     alternative library. Good options in this respect include `orjson`,
     `python-rapidjson`, and `mujson`.
 
+    This handler will raise a :class:`falcon.MediaNotFoundError` when attempting
+    to parse an empty body, or a :class:`falcon.MediaMalformedError`
+    if an error happens while parsing the body.
+
     Note:
         If you are deploying to PyPy, we recommend sticking with the standard
         library's JSON implementation, since it will be faster in most cases
@@ -80,25 +84,19 @@ class JSONHandler(BaseHandler):
             self.serialize = self._serialize_b
             self.serialize_async = self._serialize_async_b
 
-    def deserialize(self, stream, content_type, content_length):
+    def _deserialize(self, data):
+        if not data:
+            raise errors.MediaNotFoundError('JSON')
         try:
-            return self.loads(stream.read().decode())
+            return self.loads(data.decode())
         except ValueError as err:
-            raise errors.HTTPBadRequest(
-                title='Invalid JSON',
-                description='Could not parse JSON - {0}'.format(err)
-            )
+            raise errors.MediaMalformedError('JSON') from err
 
-    async def deserialize_async(self, stream, content_type, content_length):
-        data = await stream.read()
+    def deserialize(self, stream, content_type, content_length):
+        return self._deserialize(stream.read())
 
-        try:
-            return self.loads(data.decode())
-        except ValueError as err:
-            raise errors.HTTPBadRequest(
-                title='Invalid JSON',
-                description='Could not parse JSON - {0}'.format(err)
-            )
+    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()
diff --git a/falcon/media/msgpack.py b/falcon/media/msgpack.py
index d2049ac..3d69b07 100644
--- a/falcon/media/msgpack.py
+++ b/falcon/media/msgpack.py
@@ -9,10 +9,14 @@ from falcon.media.base import BaseHandler, BinaryBaseHandlerWS
 class MessagePackHandler(BaseHandler):
     """Handler built using the :py:mod:`msgpack` module.
 
-    This handler uses ``msgpack.unpackb()`` and ``msgpack.packb()``. The
+    This handler uses ``msgpack.unpackb()`` and ``msgpack.Packer().pack()``. The
     MessagePack ``bin`` type is used to distinguish between Unicode strings
     (of type ``str``) and byte strings (of type ``bytes``).
 
+    This handler will raise a :class:`falcon.MediaNotFoundError` when attempting
+    to parse an empty body; it will raise a :class:`falcon.MediaMalformedError`
+    if an error happens while parsing the body.
+
     Note:
         This handler requires the extra ``msgpack`` package (version 0.5.2
         or higher), which must be installed in addition to ``falcon`` from
@@ -29,29 +33,21 @@ class MessagePackHandler(BaseHandler):
         self.msgpack = msgpack
         self.packer = msgpack.Packer(autoreset=True, use_bin_type=True)
 
-    def deserialize(self, stream, content_type, content_length):
+    def _deserialize(self, data):
+        if not data:
+            raise errors.MediaNotFoundError('MessagePack')
         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(stream.read(), raw=False)
+            return self.msgpack.unpackb(data, raw=False)
         except ValueError as err:
-            raise errors.HTTPBadRequest(
-                title='Invalid MessagePack',
-                description='Could not parse MessagePack body - {0}'.format(err)
-            )
+            raise errors.MediaMalformedError('MessagePack') from err
 
-    async def deserialize_async(self, stream, content_type, content_length):
-        data = await stream.read()
+    def deserialize(self, stream, content_type, content_length):
+        return self._deserialize(stream.read())
 
-        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)
-        except ValueError as err:
-            raise errors.HTTPBadRequest(
-                title='Invalid MessagePack',
-                description='Could not parse MessagePack body - {0}'.format(err)
-            )
+    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)
diff --git a/falcon/media/multipart.py b/falcon/media/multipart.py
index 8097d50..820a6ee 100644
--- a/falcon/media/multipart.py
+++ b/falcon/media/multipart.py
@@ -43,7 +43,7 @@ _CRLF = b'\r\n'
 _CRLF_CRLF = _CRLF + _CRLF
 
 
-class MultipartParseError(errors.HTTPBadRequest):
+class MultipartParseError(errors.MediaMalformedError):
     """Represents a multipart form parsing error.
 
     This error may refer to a malformed or truncated form, usage of deprecated
@@ -52,12 +52,25 @@ class MultipartParseError(errors.HTTPBadRequest):
 
     :class:`MultipartParseError` instances raised in this module always include
     a short human-readable description of the error.
+
+    The cause of this exception, if any, is stored in the ``__cause__`` attribute
+    using the "raise ... from" form when raising.
+
+    Args:
+        source_error (Exception): The source exception that was the cause of this one.
     """
 
+    # NOTE(caselit): remove the description @property in MediaMalformedError
+    description = None
+
     @deprecated_args(allowed_positional=0)
-    def __init__(self, description=None, headers=None, **kwargs):
-        super().__init__(title='Malformed multipart/form-data request media',
-                         description=description, headers=headers, **kwargs)
+    def __init__(self, description=None, **kwargs):
+        errors.HTTPBadRequest.__init__(
+            self,
+            title='Malformed multipart/form-data request media',
+            description=description,
+            **kwargs
+        )
 
 
 # TODO(vytas): Consider supporting -charset- stuff.
@@ -271,9 +284,10 @@ class BodyPart:
         charset = options.get('charset', self._parse_options.default_charset)
         try:
             return self.data.decode(charset)
-        except (ValueError, LookupError):
+        except (ValueError, LookupError) as err:
             raise MultipartParseError(
-                description='invalid text or charset: {}'.format(charset))
+                description='invalid text or charset: {}'.format(charset)
+            ) from err
 
     @property
     def content_type(self):
@@ -300,10 +314,10 @@ class BodyPart:
                 charset, value = match.groups()
                 try:
                     self._filename = unquote_to_bytes(value).decode(charset)
-                except (ValueError, LookupError):
+                except (ValueError, LookupError) as err:
                     raise MultipartParseError(
-                        description='invalid text or charset: {}'.format(
-                            charset))
+                        description='invalid text or charset: {}'.format(charset)
+                    ) from err
             else:
                 value = params.get('filename')
                 if value is None:
@@ -317,7 +331,7 @@ class BodyPart:
         try:
             return misc.secure_filename(self.filename)
         except ValueError as ex:
-            raise MultipartParseError(description=str(ex))
+            raise MultipartParseError(description=str(ex)) from ex
 
     @property
     def name(self):
@@ -423,20 +437,17 @@ class MultipartForm:
                     # end of a multipart form.
                     break
                 elif separator:
-                    raise MultipartParseError(
-                        description='unexpected form structure')
+                    raise MultipartParseError(description='unexpected form structure')
 
-            except errors.DelimiterError:
-                raise MultipartParseError(
-                    description='unexpected form structure')
+            except errors.DelimiterError as err:
+                raise MultipartParseError(description='unexpected form structure') from err
 
             headers = {}
             try:
                 headers_block = stream.read_until(
                     _CRLF_CRLF, max_headers_size, consume_delimiter=True)
-            except errors.DelimiterError:
-                raise MultipartParseError(
-                    description='incomplete body part headers')
+            except errors.DelimiterError as err:
+                raise MultipartParseError(description='incomplete body part headers') from err
 
             for line in headers_block.split(_CRLF):
                 name, sep, value = line.partition(b': ')
@@ -453,9 +464,9 @@ class MultipartForm:
                     #   bodies have been discovered.
                     if name == b'content-transfer-encoding' and value != b'binary':
                         raise MultipartParseError(
-                            description=(
-                                'the deprecated Content-Transfer-Encoding '
-                                'header field is unsupported'))
+                            description=('the deprecated Content-Transfer-Encoding '
+                                         'header field is unsupported')
+                        )
                     # NOTE(vytas): RFC 7578, section 4.8.
                     #   Other header fields MUST NOT be included and MUST be
                     #   ignored.
@@ -465,7 +476,8 @@ class MultipartForm:
             remaining_parts -= 1
             if remaining_parts < 0 < self._parse_options.max_body_part_count:
                 raise MultipartParseError(
-                    description='maximum number of form body parts exceeded')
+                    description='maximum number of form body parts exceeded'
+                )
 
             yield BodyPart(stream.delimit(delimiter), headers,
                            self._parse_options)
diff --git a/falcon/media/urlencoded.py b/falcon/media/urlencoded.py
index d5aa7e0..b5cf508 100644
--- a/falcon/media/urlencoded.py
+++ b/falcon/media/urlencoded.py
@@ -1,5 +1,6 @@
 from urllib.parse import urlencode
 
+from falcon import errors
 from falcon.media.base import BaseHandler
 from falcon.util.uri import parse_query_string
 
@@ -9,7 +10,12 @@ class URLEncodedFormHandler(BaseHandler):
     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.
+    ``dict`` in a similar way that 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.
 
     Keyword Arguments:
         keep_blank (bool): Whether to keep empty-string values from the form
@@ -27,26 +33,20 @@ class URLEncodedFormHandler(BaseHandler):
         # behaviour.
         return urlencode(media, doseq=True)
 
-    def deserialize(self, stream, content_type, content_length):
-        body = stream.read()
-
-        # NOTE(kgriffs): According to http://goo.gl/6rlcux the
-        # body should be US-ASCII. Enforcing this also helps
-        # catch malicious input.
-        body = body.decode('ascii')
+    def _deserialize(self, body):
+        try:
+            # NOTE(kgriffs): According to http://goo.gl/6rlcux the
+            # body should be US-ASCII. Enforcing this also helps
+            # catch malicious input.
+            body = body.decode('ascii')
+            return parse_query_string(
+                body, keep_blank=self.keep_blank, csv=self.csv
+            )
+        except Exception as err:
+            raise errors.MediaMalformedError('URL-encoded') from err
 
-        return parse_query_string(body,
-                                  keep_blank=self.keep_blank,
-                                  csv=self.csv)
+    def deserialize(self, stream, content_type, content_length):
+        return self._deserialize(stream.read())
 
     async def deserialize_async(self, stream, content_type, content_length):
-        body = await stream.read()
-
-        # NOTE(kgriffs): According to http://goo.gl/6rlcux the
-        # body should be US-ASCII. Enforcing this also helps
-        # catch malicious input.
-        body = body.decode('ascii')
-
-        return parse_query_string(body,
-                                  keep_blank=self.keep_blank,
-                                  csv=self.csv)
+        return self._deserialize(await stream.read())
diff --git a/falcon/request.py b/falcon/request.py
index 2355763..42dcc14 100644
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -42,6 +42,8 @@ WSGI_CONTENT_HEADERS = frozenset(['CONTENT_TYPE', 'CONTENT_LENGTH'])
 strptime = datetime.strptime
 now = datetime.now
 
+_UNSET = object()  # flag object used as the default unset value
+
 
 class Request:
     """Represents a client's HTTP request.
@@ -427,6 +429,7 @@ class Request:
         'stream',
         'uri_template',
         '_media',
+        '_media_error',
         'is_websocket',
     )
 
@@ -450,7 +453,8 @@ class Request:
         self.method = env['REQUEST_METHOD']
 
         self.uri_template = None
-        self._media = None
+        self._media = _UNSET
+        self._media_error = None
 
         # NOTE(kgriffs): PEP 3333 specifies that PATH_INFO may be the
         # empty string, so normalize it in that case.
@@ -977,7 +981,7 @@ class Request:
 
         return netloc_value
 
-    def get_media(self):
+    def get_media(self, default_when_empty=_UNSET):
         """Return a deserialized form of the request stream.
 
         The first time this method is called, the request stream will be
@@ -993,13 +997,39 @@ class Request:
 
         See also :ref:`media` for more information regarding media handling.
 
+        Note:
+            When ``get_media`` is called on a request with an empty body,
+            Falcon will let the media handler try to deserialize the body
+            and will return the value returned by the handler or propagate
+            the exception raised by it. To instead return a different value
+            in case of an exception by the handler, specify the argument
+            ``default_when_empty``.
+
         Warning:
             This operation will consume the request stream the first time
             it's called and cache the results. Follow-up calls will just
             retrieve a cached version of the object.
+
+        Args:
+            default_when_empty: Fallback value to return when there is no body
+                in the request and the media handler raises an error
+                (like in the case of the default JSON media handler).
+                By default, Falcon uses the value returned by the media handler
+                or propagates the raised exception, if any.
+                This value is not cached, and will be used only for the current
+                call.
+
+        Returns:
+            media (object): The deserialized media representation.
         """
-        if self._media is not None or self.bounded_stream.eof:
+        if self._media is not _UNSET:
             return self._media
+        if self._media_error is not None:
+            if default_when_empty is not _UNSET and isinstance(
+                self._media_error, errors.MediaNotFoundError
+            ):
+                return default_when_empty
+            raise self._media_error
 
         handler = self.options.media_handlers.find_by_media_type(
             self.content_type,
@@ -1012,6 +1042,14 @@ class Request:
                 self.content_type,
                 self.content_length
             )
+        except errors.MediaNotFoundError as err:
+            self._media_error = err
+            if default_when_empty is not _UNSET:
+                return default_when_empty
+            raise
+        except Exception as err:
+            self._media_error = err
+            raise
         finally:
             if handler.exhaust_stream:
                 self.bounded_stream.exhaust()


Test patch:
diff --git a/falcon/testing/resource.py b/falcon/testing/resource.py
index b8687bf..b59d719 100644
--- a/falcon/testing/resource.py
+++ b/falcon/testing/resource.py
@@ -61,7 +61,7 @@ def capture_responder_args(req, resp, resource, params):
     if num_bytes:
         resource.captured_req_body = req.stream.read(int(num_bytes))
     elif req.get_header('capture-req-media'):
-        resource.captured_req_media = req.media
+        resource.captured_req_media = req.get_media()
 
 
 async def capture_responder_args_async(req, resp, resource, params):
diff --git a/tests/test_httperror.py b/tests/test_httperror.py
index 2663756..cf573fb 100644
--- a/tests/test_httperror.py
+++ b/tests/test_httperror.py
@@ -897,6 +897,13 @@ class TestHTTPError:
         assert response.status == falcon.HTTP_404
         assert response.json == {'title': falcon.HTTP_NOT_FOUND}
 
+    def test_MediaMalformedError(self):
+        err = falcon.MediaMalformedError('foo-media')
+        assert err.description == 'Could not parse foo-media body'
+
+        err.__cause__ = ValueError('some error')
+        assert err.description == 'Could not parse foo-media body - some error'
+
 
 def test_kw_only():
     # only deprecated for now
diff --git a/tests/test_media_multipart.py b/tests/test_media_multipart.py
index 6da3700..425c9b6 100644
--- a/tests/test_media_multipart.py
+++ b/tests/test_media_multipart.py
@@ -188,7 +188,7 @@ def test_empty_input():
     handler = media.MultipartFormHandler()
     form = handler.deserialize(
         io.BytesIO(), 'multipart/form-data; boundary=404', 0)
-    with pytest.raises(falcon.HTTPBadRequest):
+    with pytest.raises(falcon.MediaMalformedError):
         for part in form:
             pass
 
@@ -224,7 +224,7 @@ def test_invalid_text_or_charset(charset, data):
 
     form = handler.deserialize(
         io.BytesIO(data), 'multipart/form-data; boundary=BOUNDARY', len(data))
-    with pytest.raises(falcon.HTTPBadRequest):
+    with pytest.raises(falcon.MediaMalformedError):
         for part in form:
             part.text
 
@@ -313,7 +313,7 @@ def test_empty_filename():
 
     for part in form:
         assert part.filename == ''
-        with pytest.raises(falcon.HTTPBadRequest):
+        with pytest.raises(falcon.MediaMalformedError):
             part.secure_filename
 
 
diff --git a/tests/test_media_urlencoded.py b/tests/test_media_urlencoded.py
index 04de691..aa194c4 100644
--- a/tests/test_media_urlencoded.py
+++ b/tests/test_media_urlencoded.py
@@ -18,8 +18,9 @@ def test_deserialize_empty_form():
 def test_deserialize_invalid_unicode():
     handler = media.URLEncodedFormHandler()
     stream = io.BytesIO('spade=♠'.encode())
-    with pytest.raises(UnicodeDecodeError):
+    with pytest.raises(falcon.MediaMalformedError) as err:
         handler.deserialize(stream, falcon.MEDIA_URLENCODED, 9)
+    assert isinstance(err.value.__cause__, UnicodeDecodeError)
 
 
 @pytest.mark.parametrize('data,expected', [
@@ -37,7 +38,7 @@ def test_urlencoded_form_handler_serialize(data, expected):
 class MediaMirror:
 
     def on_post(self, req, resp):
-        resp.media = req.media
+        resp.media = req.get_media()
 
 
 class MediaMirrorAsync:
@@ -58,10 +59,7 @@ def test_empty_form(client):
         '/media',
         headers={'Content-Type': 'application/x-www-form-urlencoded'})
 
-    # TODO(kgriffs): The ASGI side implements the recommended fixes from
-    #   https://github.com/falconry/falcon/issues/1589 so we will need to
-    #   update this assert once the WSGI side has been updated to suit.
-    assert resp.content == (b'{}' if client.app._ASGI else b'')
+    assert resp.content == b'{}'
 
 
 @pytest.mark.parametrize('body,expected', [
diff --git a/tests/test_request_media.py b/tests/test_request_media.py
index 641d801..b9584a5 100644
--- a/tests/test_request_media.py
+++ b/tests/test_request_media.py
@@ -1,6 +1,9 @@
+import json
+
 import pytest
 
-from falcon import errors, media, testing
+import falcon
+from falcon import errors, media, testing, util
 
 from _util import create_app  # NOQA
 
@@ -21,9 +24,9 @@ def create_client(asgi, handlers=None, resource=None):
     return client
 
 
-@pytest.fixture(params=[True, False])
-def client(request):
-    return create_client(request.param)
+@pytest.fixture()
+def client(asgi):
+    return create_client(asgi)
 
 
 class ResourceCachedMedia:
@@ -94,14 +97,14 @@ def test_msgpack(asgi, media_type):
 
     # Bytes
     expected_body = b'\x81\xc4\tsomething\xc3'
-    client.simulate_post('/', body=expected_body, headers=headers)
+    assert client.simulate_post('/', body=expected_body, headers=headers).status_code == 200
 
     req_media = client.resource.captured_req_media
     assert req_media.get(b'something') is True
 
     # Unicode
     expected_body = b'\x81\xa9something\xc3'
-    client.simulate_post('/', body=expected_body, headers=headers)
+    assert client.simulate_post('/', body=expected_body, headers=headers).status_code == 200
 
     req_media = client.resource.captured_req_media
     assert req_media.get('something') is True
@@ -114,7 +117,7 @@ def test_unknown_media_type(asgi, media_type):
     client = _create_client_invalid_media(asgi, errors.HTTPUnsupportedMediaType)
 
     headers = {'Content-Type': media_type}
-    client.simulate_post('/', body=b'something', headers=headers)
+    assert client.simulate_post('/', body=b'something', headers=headers).status_code == 200
 
     title_msg = '415 Unsupported Media Type'
     description_msg = '{} is an unsupported media type.'.format(media_type)
@@ -123,29 +126,41 @@ def test_unknown_media_type(asgi, media_type):
 
 
 @pytest.mark.parametrize('media_type', [
-    ('application/json'),
+    'application/json',
+    'application/msgpack'
 ])
-def test_exhausted_stream(asgi, media_type):
-    client = create_client(asgi, {
+def test_empty_body(asgi, media_type):
+    client = _create_client_invalid_media(asgi, errors.HTTPBadRequest, {
+        'application/msgpack': media.MessagePackHandler(),
         'application/json': media.JSONHandler(),
     })
     headers = {'Content-Type': media_type}
-    client.simulate_post('/', body='', headers=headers)
+    assert client.simulate_post('/', headers=headers).status_code == 200
 
-    assert client.resource.captured_req_media is None
+    assert 'Could not parse an empty' in client.resource.captured_error.value.description
+    assert isinstance(client.resource.captured_error.value, errors.MediaNotFoundError)
 
 
 def test_invalid_json(asgi):
     client = _create_client_invalid_media(asgi, errors.HTTPBadRequest)
 
-    expected_body = b'{'
+    expected_body = '{'
     headers = {'Content-Type': 'application/json'}
-    client.simulate_post('/', body=expected_body, headers=headers)
+    assert client.simulate_post('/', body=expected_body, headers=headers).status_code == 200
+
+    assert 'Could not parse JSON body' in client.resource.captured_error.value.description
+    assert isinstance(client.resource.captured_error.value, errors.MediaMalformedError)
 
-    assert 'Could not parse JSON' in client.resource.captured_error.value.description
+    try:
+        json.loads(expected_body)
+    except Exception as e:
+        assert type(client.resource.captured_error.value.__cause__) == type(e)
+        assert str(client.resource.captured_error.value.__cause__) == str(e)
 
 
 def test_invalid_msgpack(asgi):
+    import msgpack
+
     handlers = {
         'application/msgpack': media.MessagePackHandler()
     }
@@ -153,20 +168,17 @@ def test_invalid_msgpack(asgi):
 
     expected_body = '/////////////////////'
     headers = {'Content-Type': 'application/msgpack'}
-    client.simulate_post('/', body=expected_body, headers=headers)
+    assert client.simulate_post('/', body=expected_body, headers=headers).status_code == 200
 
     desc = 'Could not parse MessagePack body - unpack(b) received extra data.'
     assert client.resource.captured_error.value.description == desc
+    assert isinstance(client.resource.captured_error.value, errors.MediaMalformedError)
 
-
-def test_invalid_stream_fails_gracefully(client):
-    client.simulate_post('/')
-
-    req = client.resource.captured_req
-    req.headers['Content-Type'] = 'application/json'
-    req._bounded_stream = None
-
-    assert client.resource.captured_req_media is None
+    try:
+        msgpack.unpackb(expected_body.encode('utf-8'))
+    except Exception as e:
+        assert type(client.resource.captured_error.value.__cause__) == type(e)
+        assert str(client.resource.captured_error.value.__cause__) == str(e)
 
 
 class NopeHandler(media.BaseHandler):
@@ -187,7 +199,7 @@ def test_complete_consumption(asgi):
     body = b'{"something": "abracadabra"}'
     headers = {'Content-Type': 'nope/nope'}
 
-    client.simulate_post('/', body=body, headers=headers)
+    assert client.simulate_post('/', body=body, headers=headers).status_code == 200
 
     req_media = client.resource.captured_req_media
     assert req_media is None
@@ -199,13 +211,14 @@ def test_complete_consumption(asgi):
 def test_empty_json_media(asgi, payload):
     resource = ResourceCachedMediaAsync() if asgi else ResourceCachedMedia()
     client = create_client(asgi, resource=resource)
-    client.simulate_post('/', json=payload)
+    assert client.simulate_post('/', json=payload).status_code == 200
     assert resource.captured_req_media == payload
 
 
 def test_null_json_media(client):
-    client.simulate_post('/', body='null',
-                         headers={'Content-Type': 'application/json'})
+    assert client.simulate_post(
+        '/', body='null', headers={'Content-Type': 'application/json'}
+    ).status_code == 200
     assert client.resource.captured_req_media is None
 
 
@@ -213,3 +226,123 @@ def _create_client_invalid_media(asgi, error_type, handlers=None):
     resource_type = ResourceInvalidMediaAsync if asgi else ResourceInvalidMedia
     resource = resource_type(error_type)
     return create_client(asgi, handlers=handlers, resource=resource)
+
+
+class FallBack:
+    def on_get(self, req, res):
+        res.media = req.get_media('fallback')
+
+
+class FallBackAsync:
+    async def on_get(self, req, res):
+        res.media = await req.get_media('fallback')
+
+
+def test_fallback(asgi):
+    client = create_client(asgi, resource=FallBackAsync() if asgi else FallBack())
+
+    res = client.simulate_get('/')
+    assert res.status_code == 200
+    assert res.json == 'fallback'
+
+
+@pytest.mark.parametrize('exhaust_stream', (True, False))
+@pytest.mark.parametrize('body', (True, False))
+def test_fallback_not_for_error_body(asgi, exhaust_stream, body):
+    js = media.JSONHandler()
+    js.exhaust_stream = exhaust_stream
+    client = create_client(asgi, resource=FallBackAsync() if asgi else FallBack(), handlers={
+        'application/json': js,
+    })
+
+    res = client.simulate_get('/', body=b'{' if body else '')
+    if body:
+        assert res.status_code == 400
+        assert 'Could not parse JSON body' in res.json['description']
+    else:
+        assert res.status_code == 200
+
+
+def test_fallback_does_not_override_media_default(asgi):
+    client = create_client(asgi, resource=FallBackAsync() if asgi else FallBack())
+
+    res = client.simulate_get('/', headers={'Content-Type': 'application/x-www-form-urlencoded'})
+    assert res.status_code == 200
+    assert res.text == '{}'
+
+
+async def _check_error(req, isasync):
+    err, err2 = None, None
+    try:
+        (await req.media) if isasync else req.media
+    except Exception as e:
+        err = e
+    try:
+        (await req.get_media()) if isasync else req.get_media()
+    except Exception as e:
+        err2 = e
+    assert err is not None
+    assert err2 is not None
+    assert err2 is err
+    if isinstance(err, errors.MediaMalformedError):
+        assert err2.__cause__ is err.__cause__
+    obj = {}
+    if req.get_param_as_bool('empty'):
+        # default_when_empty is not cached
+        res = (await req.get_media(obj)) if isasync else req.get_media(obj)
+        assert res is obj
+        err3 = None
+        try:
+            ((await req.media) if isasync else req.media) is obj
+        except Exception as e:
+            err3 = e
+        assert err3 is err
+    else:
+        err3 = None
+        try:
+            (await req.get_media(obj)) if isasync else req.get_media(obj)
+        except Exception as e:
+            err3 = e
+        assert err3 is err
+    raise errors.HTTPError(falcon.HTTP_IM_A_TEAPOT)
+
+
+class RepeatedError:
+    def on_get(self, req, resp):
+        util.async_to_sync(_check_error, req, False)
+
+
+class RepeatedErrorAsync:
+    async def on_get(self, req, resp):
+        await _check_error(req, True)
+
+
+@pytest.mark.parametrize('body', ('{', ''))
+def test_repeated_error(asgi, body):
+    client = create_client(asgi, resource=RepeatedErrorAsync() if asgi else RepeatedError())
+
+    res = client.simulate_get('/', body=body, params={'empty': not bool(body)})
+    assert res.status == falcon.HTTP_IM_A_TEAPOT
+
+
+def test_error_after_first_default(asgi):
+    async def _check_error(req, isasync):
+        assert (await req.get_media(42)) if isasync else req.get_media(42) == 42
+        try:
+            (await req.get_media()) if isasync else req.get_media()
+        except falcon.MediaNotFoundError:
+            raise falcon.HTTPStatus(falcon.HTTP_749)
+        raise falcon.HTTPStatus(falcon.HTTP_703)
+
+    class Res:
+        def on_get(self, req, resp):
+            util.async_to_sync(_check_error, req, False)
+
+    class ResAsync:
+        async def on_get(self, req, resp):
+            await _check_error(req, True)
+
+    client = create_client(asgi, resource=ResAsync() if asgi else Res())
+
+    res = client.simulate_get('/', body='')
+    assert res.status == falcon.HTTP_749