Repository: falconry/falcon

Issue:
Request hangs if method is GET and content-type is application/x-www-form-urlencoded
For some reason I have some software that sends content-type=application/x-www-form-urlencoded with GET requests.  It shouldn't, but that's not important.

Such requests hang, whereas the content-type should just be ignored.

The reason appears to be in request.py line 258

```
    if (self.content_type is not None and
            'application/x-www-form-urlencoded' in self.content_type):
        self._parse_form_urlencoded()
```

I think it probably should make the above conditional upon either the request METHOD being PUT or POST or PATCH maybe.  Or some other solution.



Discussion:
Hmm, good catch. I'll add this to the 0.2 milestone so we can get it fixed before RC1.

If it is hanging, I suspect this line of `_parse_form_urlencoded`:

``` python
body = self.stream.read()
```

Normally GETs do not have bodies, so the stream in your case is probably empty. That stream is normally just set directly to the WSGI `environ['wsgi.input']`. With that in mind, I'd like to find out what WSGI container you are using so I can try to reproduce. I know that uwsgi and gunicorn pass a nonblocking, file-like object as `environ['wsgi.input']`.

BTW, we'll likely be changing form data to be parsed on-demand in the next release, rather than automatically as it is done now. That way, if you have a case where you want to parse the request in a different way, you can do that.

I'm using gunicorn in development, probably uwsgi in production but I haven't decided yet.

As mentioned at the top of this thread, the problem is solved by just checking the request method before attempting to parse form data, and only parsing if its a method that support form data, which GET obviously doesn't.

UPDATE: I've scheduled this for 0.3. It will need a test to repro. When working on #418, care must be taken to only expect body data when the request is a POST; if it is a GET, the form fields are assumed to be encoded in the URL's query string.

I can pick this up

@dukedougal I am unable to reproduce this by GET-ing with `application/x-www-form-urlencoded` content-type against a simple hello-world route running on gunicorn. Can you please give some more details to reproduce the problem?

Without reproducing, I cannot tell whether your suggested solution, or any other, will actually fix it. And, I'd prefer to fix the underlying issue if it can be found, rather than blindly side-stepping a particular path without knowing why.

We cannot currently reproduce this issue. @dukedougal could you provide more information about how to reproduce this issue? Thanks!

@kgriffs I get the hang on `body = self.stream.read()` for a well-formed JSON POST request. (An example being `'{"username": "test-username", "password": "password"}'`). 

I was able to fix it by instead calling `body = req.stream.read(req.content_length)`. It's apparently hanging indefinitely because it never receives EOF?

I'm running 1.0.0rc2

Thanks @bcattle for the report. What WSGI server are you using to host your app? I'd like to try reproducing it myself to help track this down.

I'm using [werkzeug](http://werkzeug.pocoo.org/docs/0.11/serving/):

```
from werkzeug.serving import run_simple
run_simple('localhost', 8000, app, use_reloader=True)
```

Internally, wekzeug is using [`BaseHTTPServer.HttpServer`](https://docs.python.org/2/library/basehttpserver.html#BaseHTTPServer.HTTPServer) from the standard library. I'm on PyPy 2.6.0 on OSX.  

I'll have a look at trying to reproduce this one. 

Just an update, have not been able to reproduce this with werkzeug with PyPy 2.4 on OSX.

@bcattle do you have a sample project that demonstrates this issue?  Thanks.

Actually managed to reproduce with werkzeug and having the auto_parse_form_urlencoded options set to True.


Resolving patch:
diff --git a/falcon/api.py b/falcon/api.py
--- a/falcon/api.py
+++ b/falcon/api.py
@@ -104,8 +104,8 @@ def process_response(self, req, resp, resource, req_succeeded)
             See also: :ref:`Routing <routing>`.
 
     Attributes:
-        req_options (RequestOptions): A set of behavioral options related to
-            incoming requests.
+        req_options: A set of behavioral options related to incoming
+            requests. See also: :py:class:`~.RequestOptions`
     """
 
     # PERF(kgriffs): Reference via self since that is faster than
diff --git a/falcon/request.py b/falcon/request.py
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -50,10 +50,6 @@
 FALSE_STRINGS = ('false', 'False', 'no', '0')
 WSGI_CONTENT_HEADERS = ('CONTENT_TYPE', 'CONTENT_LENGTH')
 
-
-_maybe_wrap_wsgi_stream = True
-
-
 # PERF(kgriffs): Avoid an extra namespace lookup when using these functions
 strptime = datetime.strptime
 now = datetime.now
@@ -173,7 +169,38 @@ class Request(object):
             the header is missing.
         content_length (int): Value of the Content-Length header converted
             to an ``int``, or ``None`` if the header is missing.
-        stream: File-like object for reading the body of the request, if any.
+        stream: File-like input object for reading the body of the
+            request, if any. Since this object is provided by the WSGI
+            server itself, rather than by Falcon, it may behave
+            differently depending on how you host your app. For example,
+            attempting to read more bytes than are expected (as
+            determined by the Content-Length header) may or may not
+            block indefinitely. It's a good idea to test your WSGI
+            server to find out how it behaves.
+
+            This can be particulary problematic when a request body is
+            expected, but none is given. In this case, the following
+            call blocks under certain WSGI servers::
+
+                # Blocks if Content-Length is 0
+                data = req.stream.read()
+
+            The workaround is fairly straightforward, if verbose::
+
+                # If Content-Length happens to be 0, or the header is
+                # missing altogether, this will not block.
+                data = req.stream.read(req.content_length or 0)
+
+            Alternatively, when passing the stream directly to a
+            consumer, it may be necessary to branch off the
+            value of the Content-Length header::
+
+                if req.content_length:
+                    doc = json.load(req.stream)
+
+            For a slight performance cost, you may instead wish to use
+            :py:attr:`bounded_stream`, which wraps the native WSGI
+            input object to normalize its behavior.
 
             Note:
                 If an HTML form is POSTed to the API using the
@@ -184,6 +211,23 @@ class Request(object):
                 and merge them into the query string parameters. In this
                 case, the stream will be left at EOF.
 
+        bounded_stream: File-like wrapper around `stream` to normalize
+            certain differences between the native input objects
+            employed by different WSGI servers. In particular,
+            `bounded_stream` is aware of the expected Content-Length of
+            the body, and will never block on out-of-bounds reads,
+            assuming the client does not stall while transmitting the
+            data to the server.
+
+            For example, the following will not block when
+            Content-Length is 0 or the header is missing altogether::
+
+                data = req.bounded_stream.read()
+
+            This is also safe::
+
+                doc = json.load(req.stream)
+
         date (datetime): Value of the Date header, converted to a
             ``datetime`` instance. The header value is assumed to
             conform to RFC 1123.
@@ -246,6 +290,7 @@ class Request(object):
         'path',
         'query_string',
         'stream',
+        '_bounded_stream',
         'context',
         '_wsgierrors',
         'options',
@@ -258,14 +303,14 @@ class Request(object):
     # Child classes may override this
     context_type = None
 
-    def __init__(self, env, options=None):
-        global _maybe_wrap_wsgi_stream
+    _wsgi_input_type_known = False
+    _always_wrap_wsgi_input = False
 
+    def __init__(self, env, options=None):
         self.env = env
         self.options = options if options else RequestOptions()
 
         self._wsgierrors = env['wsgi.errors']
-        self.stream = env['wsgi.input']
         self.method = env['REQUEST_METHOD']
 
         self.uri_template = None
@@ -316,21 +361,30 @@ def __init__(self, env, options=None):
 
         # NOTE(kgriffs): Wrap wsgi.input if needed to make read() more robust,
         # normalizing semantics between, e.g., gunicorn and wsgiref.
-        if _maybe_wrap_wsgi_stream:
-            if isinstance(self.stream, (NativeStream, InputWrapper,)):
-                self._wrap_stream()
-            else:
-                # PERF(kgriffs): If self.stream does not need to be wrapped
-                # this time, it never needs to be wrapped since the server
-                # will continue using the same type for wsgi.input.
-                _maybe_wrap_wsgi_stream = False
+        if not Request._wsgi_input_type_known:
+            Request._always_wrap_wsgi_input = isinstance(
+                env['wsgi.input'],
+                (NativeStream, InputWrapper)
+            )
+
+            Request._wsgi_input_type_known = True
+
+        if Request._always_wrap_wsgi_input:
+            # TODO(kgriffs): In Falcon 2.0, stop wrapping stream since it is
+            # less useful now that we have bounded_stream.
+            self.stream = self._get_wrapped_wsgi_input()
+            self._bounded_stream = self.stream
+        else:
+            self.stream = env['wsgi.input']
+            self._bounded_stream = None  # Lazy wrapping
 
         # PERF(kgriffs): Technically, we should spend a few more
         # cycles and parse the content type for real, but
         # this heuristic will work virtually all the time.
         if (self.options.auto_parse_form_urlencoded and
                 self.content_type is not None and
-                'application/x-www-form-urlencoded' in self.content_type):
+                'application/x-www-form-urlencoded' in self.content_type and
+                self.method == 'POST'):
             self._parse_form_urlencoded()
 
         if self.context_type is None:
@@ -402,6 +456,13 @@ def content_length(self):
 
         return value_as_int
 
+    @property
+    def bounded_stream(self):
+        if self._bounded_stream is None:
+            self._bounded_stream = self._get_wrapped_wsgi_input()
+
+        return self._bounded_stream
+
     @property
     def date(self):
         return self.get_header_as_datetime('Date')
@@ -750,9 +811,12 @@ def get_param(self, name, required=False, store=None, default=None):
 
         Note:
             If an HTML form is POSTed to the API using the
-            *application/x-www-form-urlencoded* media type, the
-            parameters from the request body will be merged into
-            the query string parameters.
+            *application/x-www-form-urlencoded* media type, Falcon can
+            automatically parse the parameters from the request body
+            and merge them into the query string parameters. To enable
+            this functionality, set
+            :py:attr:`~.RequestOptions.auto_parse_form_urlencoded` to
+            ``True`` via :any:`API.req_options`.
 
             If a key appears more than once in the form data, one of the
             values will be returned as a string, but it is undefined which
@@ -1126,7 +1190,7 @@ def log_error(self, message):
     # Helpers
     # ------------------------------------------------------------------------
 
-    def _wrap_stream(self):
+    def _get_wrapped_wsgi_input(self):
         try:
             content_length = self.content_length or 0
 
@@ -1137,18 +1201,14 @@ def _wrap_stream(self):
             # but it had an invalid value. Assume no content.
             content_length = 0
 
-        self.stream = helpers.Body(self.stream, content_length)
+        return helpers.BoundedStream(self.env['wsgi.input'], content_length)
 
     def _parse_form_urlencoded(self):
-        # NOTE(kgriffs): This assumes self.stream has been patched
-        # above in the case of wsgiref, so that self.content_length
-        # is not needed. Normally we just avoid accessing
-        # self.content_length, because it is a little expensive
-        # to call. We could cache self.content_length, but the
-        # overhead to do that won't usually be helpful, since
-        # content length will only ever be read once per
-        # request in most cases.
-        body = self.stream.read()
+        content_length = self.content_length
+        if not content_length:
+            return
+
+        body = self.stream.read(content_length)
 
         # NOTE(kgriffs): According to http://goo.gl/6rlcux the
         # body should be US-ASCII. Enforcing this also helps
@@ -1201,7 +1261,10 @@ def _parse_rfc_forwarded(self):
 
 # PERF: To avoid typos and improve storage space and speed over a dict.
 class RequestOptions(object):
-    """This class is a container for ``Request`` options.
+    """Defines a set of configurable request options.
+
+    An instance of this class is exposed via :any:`API.req_options` for
+    configuring certain :py:class:`~.Request` behaviors.
 
     Attributes:
         keep_blank_qs_values (bool): Set to ``True`` to keep query string
diff --git a/falcon/request_helpers.py b/falcon/request_helpers.py
--- a/falcon/request_helpers.py
+++ b/falcon/request_helpers.py
@@ -36,7 +36,7 @@ def fget(self):
     return property(fget)
 
 
-class Body(object):
+class BoundedStream(object):
     """Wrap *wsgi.input* streams to make them more robust.
 
     ``socket._fileobject`` and ``io.BufferedReader`` are sometimes used
@@ -74,11 +74,11 @@ def _read(self, size, target):
         """Helper function for proxing reads to the underlying stream.
 
         Args:
-            size (int): Maximum number of bytes/characters to read.
-                Will be coerced, if None or -1, to `self.stream_len`. Will
-                likewise be coerced if greater than `self.stream_len`, so
-                that if the stream doesn't follow standard io semantics,
-                the read won't block.
+            size (int): Maximum number of bytes to read. Will be
+                coerced, if None or -1, to the number of remaining bytes
+                in the stream. Will likewise be coerced if greater than
+                the number of remaining bytes, to avoid making a
+                blocking call to the wrapped stream.
             target (callable): Once `size` has been fixed up, this function
                 will be called to actually do the work.
 
@@ -137,3 +137,7 @@ def readlines(self, hint=None):
         """
 
         return self._read(hint, self.stream.readlines)
+
+
+# NOTE(kgriffs): Alias for backwards-compat
+Body = BoundedStream


Test patch:
diff --git a/falcon/testing/base.py b/falcon/testing/base.py
--- a/falcon/testing/base.py
+++ b/falcon/testing/base.py
@@ -27,7 +27,10 @@
 from falcon.testing.srmock import StartResponseMock
 
 
-class TestBase(unittest.TestCase):
+# NOTE(kgriffs): Since this class is deprecated and we will be using it
+# less and less for Falcon's own tests, coverage may be reduced, hence
+# the pragma to ignore coverage errors from now on.
+class TestBase(unittest.TestCase):  # pragma nocover
     """Extends :py:mod:`unittest` to support WSGI functional testing.
 
     Warning:
diff --git a/falcon/testing/resource.py b/falcon/testing/resource.py
--- a/falcon/testing/resource.py
+++ b/falcon/testing/resource.py
@@ -66,9 +66,10 @@ class SimpleTestResource(object):
     as needed to test middleware, hooks, and the Falcon framework
     itself.
 
-    Only the ``on_get()`` responder is implemented; when adding
-    additional responders in child classes, they can be decorated
-    with the :py:meth:`falcon.testing.capture_responder_args` hook in
+    Only noop ``on_get()`` and ``on_post()`` responders are implemented;
+    when overriding these, or adding additional responders in child
+    classes, they can be decorated with the
+    :py:meth:`falcon.testing.capture_responder_args` hook in
     order to capture the *req*, *resp*, and *params* arguments that
     are passed to the responder. Responders may also be decorated with
     the :py:meth:`falcon.testing.set_resp_defaults` hook in order to
@@ -108,11 +109,20 @@ def __init__(self, status=None, body=None, json=None, headers=None):
         else:
             self._default_body = body
 
+        self.captured_req = None
+        self.captured_resp = None
+        self.captured_kwargs = None
+
     @falcon.before(capture_responder_args)
     @falcon.before(set_resp_defaults)
     def on_get(self, req, resp, **kwargs):
         pass
 
+    @falcon.before(capture_responder_args)
+    @falcon.before(set_resp_defaults)
+    def on_post(self, req, resp, **kwargs):
+        pass
+
 
 class TestResource(object):
     """Mock resource for functional testing.
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,12 @@
+import pytest
+
+import falcon
+
+
+# NOTE(kgriffs): Some modules actually run a wsgiref server, so
+# to ensure we reset the detection for the other modules, we just
+# run this fixture before each one is tested.
+@pytest.fixture(autouse=True, scope='module')
+def reset_request_stream_detection():
+    falcon.Request._wsgi_input_type_known = False
+    falcon.Request._always_wrap_wsgi_input = False
diff --git a/tests/test_query_params.py b/tests/test_query_params.py
--- a/tests/test_query_params.py
+++ b/tests/test_query_params.py
@@ -12,14 +12,14 @@
 class _TestQueryParams(testing.TestBase):
 
     def before(self):
-        self.resource = testing.TestResource()
+        self.resource = testing.SimpleTestResource()
         self.api.add_route('/', self.resource)
 
     def test_none(self):
         query_string = ''
         self.simulate_request('/', query_string=query_string)
 
-        req = self.resource.req
+        req = self.resource.captured_req
         store = {}
         self.assertIs(req.get_param('marker'), None)
         self.assertIs(req.get_param('limit', store), None)
@@ -32,7 +32,7 @@ def test_blank(self):
         query_string = 'marker='
         self.simulate_request('/', query_string=query_string)
 
-        req = self.resource.req
+        req = self.resource.captured_req
         self.assertIs(req.get_param('marker'), None)
 
         store = {}
@@ -43,7 +43,7 @@ def test_simple(self):
         query_string = 'marker=deadbeef&limit=25'
         self.simulate_request('/', query_string=query_string)
 
-        req = self.resource.req
+        req = self.resource.captured_req
         store = {}
         self.assertEqual(req.get_param('marker', store=store) or 'nada',
                          'deadbeef')
@@ -56,7 +56,7 @@ def test_percent_encoded(self):
         query_string = 'id=23,42&q=%e8%b1%86+%e7%93%a3'
         self.simulate_request('/', query_string=query_string)
 
-        req = self.resource.req
+        req = self.resource.captured_req
 
         # NOTE(kgriffs): For lists, get_param will return one of the
         # elements, but which one it will choose is undefined.
@@ -71,7 +71,7 @@ def test_option_auto_parse_qs_csv_simple_false(self):
         query_string = 'id=23,42,,&id=2'
         self.simulate_request('/', query_string=query_string)
 
-        req = self.resource.req
+        req = self.resource.captured_req
 
         self.assertEqual(req.params['id'], [u'23,42,,', u'2'])
         self.assertIn(req.get_param('id'), [u'23,42,,', u'2'])
@@ -83,7 +83,7 @@ def test_option_auto_parse_qs_csv_simple_true(self):
         query_string = 'id=23,42,,&id=2'
         self.simulate_request('/', query_string=query_string)
 
-        req = self.resource.req
+        req = self.resource.captured_req
 
         self.assertEqual(req.params['id'], [u'23', u'42', u'2'])
         self.assertIn(req.get_param('id'), [u'23', u'42', u'2'])
@@ -102,7 +102,7 @@ def test_option_auto_parse_qs_csv_complex_false(self):
 
         self.simulate_request('/', query_string=query_string)
 
-        req = self.resource.req
+        req = self.resource.captured_req
 
         self.assertIn(req.get_param('colors'), 'red,green,blue')
         self.assertEqual(req.get_param_as_list('colors'), [u'red,green,blue'])
@@ -124,7 +124,7 @@ def test_bad_percentage(self):
         self.simulate_request('/', query_string=query_string)
         self.assertEqual(self.srmock.status, falcon.HTTP_200)
 
-        req = self.resource.req
+        req = self.resource.captured_req
         self.assertEqual(req.get_param('x'), '% % %')
         self.assertEqual(req.get_param('y'), 'peregrine')
         self.assertEqual(req.get_param('z'), '%a%z%zz%1 e')
@@ -135,7 +135,7 @@ def test_allowed_names(self):
                         '_thing=42&_charset_=utf-8')
         self.simulate_request('/', query_string=query_string)
 
-        req = self.resource.req
+        req = self.resource.captured_req
         self.assertEqual(req.get_param('p'), '0')
         self.assertEqual(req.get_param('p1'), '23')
         self.assertEqual(req.get_param('2p'), 'foo')
@@ -153,7 +153,7 @@ def test_required(self, method_name):
         query_string = ''
         self.simulate_request('/', query_string=query_string)
 
-        req = self.resource.req
+        req = self.resource.captured_req
 
         try:
             getattr(req, method_name)('marker', required=True)
@@ -168,7 +168,7 @@ def test_int(self):
         query_string = 'marker=deadbeef&limit=25'
         self.simulate_request('/', query_string=query_string)
 
-        req = self.resource.req
+        req = self.resource.captured_req
 
         try:
             req.get_param_as_int('marker')
@@ -230,7 +230,7 @@ def test_int_neg(self):
         query_string = 'marker=deadbeef&pos=-7'
         self.simulate_request('/', query_string=query_string)
 
-        req = self.resource.req
+        req = self.resource.captured_req
         self.assertEqual(req.get_param_as_int('pos'), -7)
 
         self.assertEqual(
@@ -260,7 +260,7 @@ def test_boolean(self):
                         't1=True&f1=False&t2=yes&f2=no&blank&one=1&zero=0')
         self.simulate_request('/', query_string=query_string)
 
-        req = self.resource.req
+        req = self.resource.captured_req
         self.assertRaises(falcon.HTTPBadRequest, req.get_param_as_bool,
                           'bogus')
 
@@ -296,7 +296,7 @@ def test_boolean_blank(self):
             query_string='blank&blank2=',
         )
 
-        req = self.resource.req
+        req = self.resource.captured_req
         self.assertEqual(req.get_param('blank'), '')
         self.assertEqual(req.get_param('blank2'), '')
         self.assertRaises(falcon.HTTPInvalidParam, req.get_param_as_bool,
@@ -316,7 +316,7 @@ def test_list_type(self):
                         '&thing_two=1&thing_two=&thing_two=3')
         self.simulate_request('/', query_string=query_string)
 
-        req = self.resource.req
+        req = self.resource.captured_req
 
         # NOTE(kgriffs): For lists, get_param will return one of the
         # elements, but which one it will choose is undefined.
@@ -366,7 +366,7 @@ def test_list_type_blank(self):
             query_string=query_string
         )
 
-        req = self.resource.req
+        req = self.resource.captured_req
 
         # NOTE(kgriffs): For lists, get_param will return one of the
         # elements, but which one it will choose is undefined.
@@ -412,7 +412,7 @@ def test_list_transformer(self):
         query_string = 'coord=1.4,13,15.1&limit=100&things=4,,1'
         self.simulate_request('/', query_string=query_string)
 
-        req = self.resource.req
+        req = self.resource.captured_req
 
         # NOTE(kgriffs): For lists, get_param will return one of the
         # elements, but which one it will choose is undefined.
@@ -443,7 +443,7 @@ def test_param_property(self):
         query_string = 'ant=4&bee=3&cat=2&dog=1'
         self.simulate_request('/', query_string=query_string)
 
-        req = self.resource.req
+        req = self.resource.captured_req
         self.assertEqual(
             sorted(req.params.items()),
             [('ant', '4'), ('bee', '3'), ('cat', '2'), ('dog', '1')])
@@ -452,7 +452,7 @@ def test_multiple_form_keys(self):
         query_string = 'ant=1&ant=2&bee=3&cat=6&cat=5&cat=4'
         self.simulate_request('/', query_string=query_string)
 
-        req = self.resource.req
+        req = self.resource.captured_req
         # By definition, we cannot guarantee which of the multiple keys will
         # be returned by .get_param().
         self.assertIn(req.get_param('ant'), ('1', '2'))
@@ -464,20 +464,20 @@ def test_multiple_form_keys(self):
     def test_multiple_keys_as_bool(self):
         query_string = 'ant=true&ant=yes&ant=True'
         self.simulate_request('/', query_string=query_string)
-        req = self.resource.req
+        req = self.resource.captured_req
         self.assertEqual(req.get_param_as_bool('ant'), True)
 
     def test_multiple_keys_as_int(self):
         query_string = 'ant=1&ant=2&ant=3'
         self.simulate_request('/', query_string=query_string)
-        req = self.resource.req
+        req = self.resource.captured_req
         self.assertIn(req.get_param_as_int('ant'), (1, 2, 3))
 
     def test_multiple_form_keys_as_list(self):
         query_string = 'ant=1&ant=2&bee=3&cat=6&cat=5&cat=4'
         self.simulate_request('/', query_string=query_string)
 
-        req = self.resource.req
+        req = self.resource.captured_req
         # There are two 'ant' keys.
         self.assertEqual(req.get_param_as_list('ant'), ['1', '2'])
         # There is only one 'bee' key..
@@ -489,14 +489,14 @@ def test_get_date_valid(self):
         date_value = '2015-04-20'
         query_string = 'thedate={0}'.format(date_value)
         self.simulate_request('/', query_string=query_string)
-        req = self.resource.req
+        req = self.resource.captured_req
         self.assertEqual(req.get_param_as_date('thedate'),
                          date(2015, 4, 20))
 
     def test_get_date_missing_param(self):
         query_string = 'notthedate=2015-04-20'
         self.simulate_request('/', query_string=query_string)
-        req = self.resource.req
+        req = self.resource.captured_req
         self.assertEqual(req.get_param_as_date('thedate'),
                          None)
 
@@ -505,7 +505,7 @@ def test_get_date_valid_with_format(self):
         query_string = 'thedate={0}'.format(date_value)
         format_string = '%Y%m%d'
         self.simulate_request('/', query_string=query_string)
-        req = self.resource.req
+        req = self.resource.captured_req
         self.assertEqual(req.get_param_as_date('thedate',
                          format_string=format_string),
                          date(2015, 4, 20))
@@ -514,7 +514,7 @@ def test_get_date_store(self):
         date_value = '2015-04-20'
         query_string = 'thedate={0}'.format(date_value)
         self.simulate_request('/', query_string=query_string)
-        req = self.resource.req
+        req = self.resource.captured_req
         store = {}
         req.get_param_as_date('thedate', store=store)
         self.assertNotEqual(len(store), 0)
@@ -524,7 +524,7 @@ def test_get_date_invalid(self):
         query_string = 'thedate={0}'.format(date_value)
         format_string = '%Y%m%d'
         self.simulate_request('/', query_string=query_string)
-        req = self.resource.req
+        req = self.resource.captured_req
         self.assertRaises(HTTPInvalidParam, req.get_param_as_date,
                           'thedate', format_string=format_string)
 
@@ -532,7 +532,7 @@ def test_get_dict_valid(self):
         payload_dict = {'foo': 'bar'}
         query_string = 'payload={0}'.format(json.dumps(payload_dict))
         self.simulate_request('/', query_string=query_string)
-        req = self.resource.req
+        req = self.resource.captured_req
         self.assertEqual(req.get_param_as_dict('payload'),
                          payload_dict)
 
@@ -540,7 +540,7 @@ def test_get_dict_missing_param(self):
         payload_dict = {'foo': 'bar'}
         query_string = 'notthepayload={0}'.format(json.dumps(payload_dict))
         self.simulate_request('/', query_string=query_string)
-        req = self.resource.req
+        req = self.resource.captured_req
         self.assertEqual(req.get_param_as_dict('payload'),
                          None)
 
@@ -548,7 +548,7 @@ def test_get_dict_store(self):
         payload_dict = {'foo': 'bar'}
         query_string = 'payload={0}'.format(json.dumps(payload_dict))
         self.simulate_request('/', query_string=query_string)
-        req = self.resource.req
+        req = self.resource.captured_req
         store = {}
         req.get_param_as_dict('payload', store=store)
         self.assertNotEqual(len(store), 0)
@@ -557,7 +557,7 @@ def test_get_dict_invalid(self):
         payload_dict = 'foobar'
         query_string = 'payload={0}'.format(payload_dict)
         self.simulate_request('/', query_string=query_string)
-        req = self.resource.req
+        req = self.resource.captured_req
         self.assertRaises(HTTPInvalidParam, req.get_param_as_dict,
                           'payload')
 
@@ -568,23 +568,42 @@ def before(self):
         self.api.req_options.auto_parse_form_urlencoded = True
 
     def simulate_request(self, path, query_string, **kwargs):
-        headers = {'Content-Type': 'application/x-www-form-urlencoded'}
+
+        headers = kwargs.setdefault('headers', {})
+        headers['Content-Type'] = 'application/x-www-form-urlencoded'
+
         super(PostQueryParams, self).simulate_request(
-            path, body=query_string, headers=headers, **kwargs)
+            path,
+            method='POST',
+            body=query_string,
+            **kwargs
+        )
 
     def test_non_ascii(self):
         value = u'\u8c46\u74e3'
         query_string = b'q=' + value.encode('utf-8')
         self.simulate_request('/', query_string=query_string)
 
-        req = self.resource.req
+        req = self.resource.captured_req
+        self.assertIs(req.get_param('q'), None)
+
+    def test_empty_body(self):
+        self.simulate_request('/', query_string=None)
+
+        req = self.resource.captured_req
+        self.assertIs(req.get_param('q'), None)
+
+    def test_empty_body_no_content_length(self):
+        self.simulate_request('/', query_string=None)
+
+        req = self.resource.captured_req
         self.assertIs(req.get_param('q'), None)
 
     def test_explicitly_disable_auto_parse(self):
         self.api.req_options.auto_parse_form_urlencoded = False
         self.simulate_request('/', query_string='q=42')
 
-        req = self.resource.req
+        req = self.resource.captured_req
         self.assertIs(req.get_param('q'), None)
 
 
@@ -596,11 +615,11 @@ def simulate_request(self, path, query_string, **kwargs):
 
 class PostQueryParamsDefaultBehavior(testing.TestBase):
     def test_dont_auto_parse_by_default(self):
-        self.resource = testing.TestResource()
+        self.resource = testing.SimpleTestResource()
         self.api.add_route('/', self.resource)
 
         headers = {'Content-Type': 'application/x-www-form-urlencoded'}
         self.simulate_request('/', body='q=42', headers=headers)
 
-        req = self.resource.req
+        req = self.resource.captured_req
         self.assertIs(req.get_param('q'), None)
diff --git a/tests/test_request_body.py b/tests/test_request_body.py
--- a/tests/test_request_body.py
+++ b/tests/test_request_body.py
@@ -1,8 +1,4 @@
 import io
-import threading
-from wsgiref import simple_server
-
-import requests
 
 import falcon
 from falcon import request_helpers
@@ -64,42 +60,21 @@ def test_read_body(self):
 
         self.assertEqual(stream.tell(), expected_len)
 
-    def test_read_socket_body(self):
-        expected_body = testing.rand_string(SIZE_1_KB / 2, SIZE_1_KB)
-
-        def server():
-            class Echo(object):
-                def on_post(self, req, resp):
-                    # wsgiref socket._fileobject blocks when len not given,
-                    # but Falcon is smarter than that. :D
-                    body = req.stream.read()
-                    resp.body = body
-
-                def on_put(self, req, resp):
-                    # wsgiref socket._fileobject blocks when len too long,
-                    # but Falcon should work around that for me.
-                    body = req.stream.read(req.content_length + 1)
-                    resp.body = body
-
-            api = falcon.API()
-            api.add_route('/echo', Echo())
-
-            httpd = simple_server.make_server('127.0.0.1', 8989, api)
-            httpd.serve_forever()
+    def test_bounded_stream_property_empty_body(self):
+        """Test that we can get a bounded stream outside of wsgiref."""
 
-        thread = threading.Thread(target=server)
-        thread.daemon = True
-        thread.start()
+        environ = testing.create_environ()
+        req = falcon.Request(environ)
 
-        # Let it boot
-        thread.join(1)
+        bounded_stream = req.bounded_stream
 
-        url = 'http://127.0.0.1:8989/echo'
-        resp = requests.post(url, data=expected_body)
-        self.assertEqual(resp.text, expected_body)
+        # NOTE(kgriffs): Verify that we aren't creating a new object
+        # each time the property is called. Also ensures branch
+        # coverage of the property implementation.
+        assert bounded_stream is req.bounded_stream
 
-        resp = requests.put(url, data=expected_body)
-        self.assertEqual(resp.text, expected_body)
+        data = bounded_stream.read()
+        self.assertEqual(len(data), 0)
 
     def test_body_stream_wrapper(self):
         data = testing.rand_string(SIZE_1_KB / 2, SIZE_1_KB)
diff --git a/tests/test_wsgi.py b/tests/test_wsgi.py
--- a/tests/test_wsgi.py
+++ b/tests/test_wsgi.py
@@ -7,25 +7,70 @@
 except ImportError:
     pass  # Jython
 
+import pytest
 import requests
-from testtools.matchers import Equals, MatchesRegex
 
 import falcon
+from falcon.request_helpers import BoundedStream
 import falcon.testing as testing
 
 _SERVER_HOST = 'localhost'
 _SERVER_PORT = 9809
 _SERVER_BASE_URL = 'http://{0}:{1}/'.format(_SERVER_HOST, _SERVER_PORT)
+_SIZE_1_KB = 1024
 
 
-def _is_iterable(thing):
-    try:
-        for i in thing:
-            break
+@pytest.mark.skipif(
+    # NOTE(kgriffs): Jython does not support the multiprocessing
+    # module. We could alternatively implement these tests
+    # using threads, but then we have to force a garbage
+    # collection in between each test in order to make
+    # the server relinquish its socket, and the gc module
+    # doesn't appear to do anything under Jython.
+
+    'java' in sys.platform,
+    reason='Incompatible with Jython'
+)
+@pytest.mark.usefixtures('_setup_wsgi_server')
+class TestWSGIServer(object):
+
+    def test_get(self):
+        resp = requests.get(_SERVER_BASE_URL)
+        assert resp.status_code == 200
+        assert resp.text == '127.0.0.1'
+
+    def test_put(self):
+        body = '{}'
+        resp = requests.put(_SERVER_BASE_URL, data=body)
+        assert resp.status_code == 200
+        assert resp.text == '{}'
+
+    def test_head_405(self):
+        body = '{}'
+        resp = requests.head(_SERVER_BASE_URL, data=body)
+        assert resp.status_code == 405
+
+    def test_post(self):
+        body = testing.rand_string(_SIZE_1_KB / 2, _SIZE_1_KB)
+        resp = requests.post(_SERVER_BASE_URL, data=body)
+        assert resp.status_code == 200
+        assert resp.text == body
+
+    def test_post_invalid_content_length(self):
+        headers = {'Content-Length': 'invalid'}
+        resp = requests.post(_SERVER_BASE_URL, headers=headers)
+        assert resp.status_code == 200
+        assert resp.text == ''
+
+    def test_post_read_bounded_stream(self):
+        body = testing.rand_string(_SIZE_1_KB / 2, _SIZE_1_KB)
+        resp = requests.post(_SERVER_BASE_URL + 'bucket', data=body)
+        assert resp.status_code == 200
+        assert resp.text == body
 
-        return True
-    except:
-        return False
+    def test_post_read_bounded_stream_no_body(self):
+        resp = requests.post(_SERVER_BASE_URL + 'bucket')
+        assert not resp.text
 
 
 def _run_server(stop_event):
@@ -34,7 +79,7 @@ def on_get(self, req, resp):
             resp.body = req.remote_addr
 
         def on_post(self, req, resp):
-            resp.body = req.stream.read(1000)
+            resp.body = req.stream.read(_SIZE_1_KB)
 
         def on_put(self, req, resp):
             # NOTE(kgriffs): Test that reading past the end does
@@ -44,8 +89,36 @@ def on_put(self, req, resp):
 
             resp.body = b''.join(req_body)
 
+    class Bucket(object):
+        def on_post(self, req, resp):
+            # NOTE(kgriffs): The framework automatically detects
+            # wsgiref's input object type and wraps it; we'll probably
+            # do away with this at some point, but for now we
+            # verify the functionality,
+            assert isinstance(req.stream, BoundedStream)
+
+            # NOTE(kgriffs): Ensure we are reusing the same object for
+            # the sake of efficiency and to ensure a shared state of the
+            # stream. (only in the case that we have decided to
+            # automatically wrap the WSGI input object, i.e. when
+            # running under wsgiref or similar).
+            assert req.stream is req.bounded_stream
+
+            # NOTE(kgriffs): This would normally block when
+            # Content-Length is 0 and the WSGI input object.
+            # BoundedStream fixes that. This is just a sanity check to
+            # make sure req.bounded_stream is what we think it is;
+            # BoundedStream itself has its own unit tests in
+            # test_request_body.py
+            resp.body = req.bounded_stream.read()
+
+            # NOTE(kgriffs): No need to also test the same read() for
+            # req.stream, since we already asserted they are the same
+            # objects.
+
     api = application = falcon.API()
     api.add_route('/', Things())
+    api.add_route('/bucket', Bucket())
 
     server = make_server(_SERVER_HOST, _SERVER_PORT, application)
 
@@ -53,103 +126,29 @@ def on_put(self, req, resp):
         server.handle_request()
 
 
-class TestWSGIInterface(testing.TestBase):
-
-    def test_srmock(self):
-        mock = testing.StartResponseMock()
-        mock(falcon.HTTP_200, ())
-
-        self.assertEqual(falcon.HTTP_200, mock.status)
-        self.assertEqual(None, mock.exc_info)
-
-        mock = testing.StartResponseMock()
-        exc_info = sys.exc_info()
-        mock(falcon.HTTP_200, (), exc_info)
-
-        self.assertEqual(exc_info, mock.exc_info)
+@pytest.fixture
+def _setup_wsgi_server():
+    stop_event = multiprocessing.Event()
+    process = multiprocessing.Process(
+        target=_run_server,
+        args=(stop_event,)
+    )
 
-    def test_pep3333(self):
-        api = falcon.API()
-        mock = testing.StartResponseMock()
+    process.start()
 
-        # Simulate a web request (normally done though a WSGI server)
-        response = api(testing.create_environ(), mock)
+    # NOTE(kgriffs): Let the server start up
+    time.sleep(0.2)
 
-        # Verify that the response is iterable
-        self.assertTrue(_is_iterable(response))
+    yield
 
-        # Make sure start_response was passed a valid status string
-        self.assertIs(mock.call_count, 1)
-        self.assertTrue(isinstance(mock.status, str))
-        self.assertThat(mock.status, MatchesRegex('^\d+[a-zA-Z\s]+$'))
+    stop_event.set()
 
-        # Verify headers is a list of tuples, each containing a pair of strings
-        self.assertTrue(isinstance(mock.headers, list))
-        if len(mock.headers) != 0:
-            header = mock.headers[0]
-            self.assertTrue(isinstance(header, tuple))
-            self.assertThat(len(header), Equals(2))
-            self.assertTrue(isinstance(header[0], str))
-            self.assertTrue(isinstance(header[1], str))
-
-
-class TestWSGIReference(testing.TestBase):
-
-    def before(self):
-        if 'java' in sys.platform:
-            # NOTE(kgriffs): Jython does not support the multiprocessing
-            # module. We could alternatively implement these tests
-            # using threads, but then we have to force a garbage
-            # collection in between each test in order to make
-            # the server relinquish its socket, and the gc module
-            # doesn't appear to do anything under Jython.
-            self.skip('Incompatible with Jython')
-
-        self._stop_event = multiprocessing.Event()
-        self._process = multiprocessing.Process(target=_run_server,
-                                                args=(self._stop_event,))
-        self._process.start()
-
-        # NOTE(kgriffs): Let the server start up
-        time.sleep(0.2)
-
-    def after(self):
-        self._stop_event.set()
-
-        # NOTE(kgriffs): Pump the request handler loop in case execution
-        # made it to the next server.handle_request() before we sent the
-        # event.
-        try:
-            requests.get(_SERVER_BASE_URL)
-        except Exception:
-            pass  # Thread already exited
-
-        self._process.join()
-
-    def test_wsgiref_get(self):
-        resp = requests.get(_SERVER_BASE_URL)
-        self.assertEqual(resp.status_code, 200)
-        self.assertEqual(resp.text, '127.0.0.1')
-
-    def test_wsgiref_put(self):
-        body = '{}'
-        resp = requests.put(_SERVER_BASE_URL, data=body)
-        self.assertEqual(resp.status_code, 200)
-        self.assertEqual(resp.text, '{}')
-
-    def test_wsgiref_head_405(self):
-        body = '{}'
-        resp = requests.head(_SERVER_BASE_URL, data=body)
-        self.assertEqual(resp.status_code, 405)
-
-    def test_wsgiref_post(self):
-        body = '{}'
-        resp = requests.post(_SERVER_BASE_URL, data=body)
-        self.assertEqual(resp.status_code, 200)
-        self.assertEqual(resp.text, '{}')
+    # NOTE(kgriffs): Pump the request handler loop in case execution
+    # made it to the next server.handle_request() before we sent the
+    # event.
+    try:
+        requests.get(_SERVER_BASE_URL)
+    except Exception:
+        pass  # Thread already exited
 
-    def test_wsgiref_post_invalid_content_length(self):
-        headers = {'Content-Length': 'invalid'}
-        resp = requests.post(_SERVER_BASE_URL, headers=headers)
-        self.assertEqual(resp.status_code, 200)
-        self.assertEqual(resp.text, '')
+    process.join()
diff --git a/tests/test_wsgi_interface.py b/tests/test_wsgi_interface.py
new file mode 100644
--- /dev/null
+++ b/tests/test_wsgi_interface.py
@@ -0,0 +1,55 @@
+import re
+import sys
+
+import falcon
+import falcon.testing as testing
+
+
+class TestWSGIInterface(object):
+
+    def test_srmock(self):
+        mock = testing.StartResponseMock()
+        mock(falcon.HTTP_200, ())
+
+        assert mock.status == falcon.HTTP_200
+        assert mock.exc_info is None
+
+        mock = testing.StartResponseMock()
+        exc_info = sys.exc_info()
+        mock(falcon.HTTP_200, (), exc_info)
+
+        assert mock.exc_info == exc_info
+
+    def test_pep3333(self):
+        api = falcon.API()
+        mock = testing.StartResponseMock()
+
+        # Simulate a web request (normally done though a WSGI server)
+        response = api(testing.create_environ(), mock)
+
+        # Verify that the response is iterable
+        assert _is_iterable(response)
+
+        # Make sure start_response was passed a valid status string
+        assert mock.call_count == 1
+        assert isinstance(mock.status, str)
+        assert re.match('^\d+[a-zA-Z\s]+$', mock.status)
+
+        # Verify headers is a list of tuples, each containing a pair of strings
+        assert isinstance(mock.headers, list)
+        if len(mock.headers) != 0:
+            header = mock.headers[0]
+            assert isinstance(header, tuple)
+            assert len(header) == 2
+            assert isinstance(header[0], str)
+            assert isinstance(header[1], str)
+
+
+def _is_iterable(thing):
+    try:
+        for i in thing:
+            break
+
+        return True
+    except:
+        return False