Skip to content

Commit bdceedf

Browse files
author
kgriffs
committed
feat(Request): Normalize wsgi.input semantics
The socket._fileobject and io.BufferedReader are sometimes used to implement wsgi.input. However, app developers are often burned by the fact that the read() method for these objects block indefinitely if either no size is passed, or a size greater than the request's content length is passed to the method. This patch makes Falcon detect when the above native stream types are used by a WSGI server, and wraps them with a simple Body object that provides more forgiving read, readline, and readlines methods than what is otherwise provided. The end result is that app developers are shielded from this silly inconsistency between WSGI servers. Fixes issue falconry#147
1 parent efed49b commit bdceedf

File tree

5 files changed

+174
-3
lines changed

5 files changed

+174
-3
lines changed

falcon/request.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@
1818

1919
from datetime import datetime
2020

21+
try:
22+
# NOTE(kgrifs): In Python 2.6 and 2.7, socket._fileobject is a
23+
# standard way of exposing a socket as a file-like object, and
24+
# is used by wsgiref for wsgi.input.
25+
import socket
26+
NativeStream = socket._fileobject
27+
except AttributeError: # pragma nocover
28+
# NOTE(kgriffs): In Python 3.3, wsgiref implements wsgi.input
29+
# using _io.BufferedReader which is an alias of io.BufferedReader
30+
import io
31+
NativeStream = io.BufferedReader
32+
2133
import mimeparse
2234
import six
2335

@@ -81,7 +93,6 @@ def __init__(self, env):
8193

8294
self._wsgierrors = env['wsgi.errors']
8395
self.stream = env['wsgi.input']
84-
8596
self.method = env['REQUEST_METHOD']
8697

8798
# Normalize path
@@ -109,6 +120,14 @@ def __init__(self, env):
109120

110121
self._headers = helpers.parse_headers(env)
111122

123+
# NOTE(kgriffs): Wrap wsgi.input if needed to make read() more robust,
124+
# normalizing semantics between, e.g., gunicorn and wsgiref.
125+
if isinstance(self.stream, NativeStream): # pragma: nocover
126+
# NOTE(kgriffs): coverage can't detect that this *is* actually
127+
# covered since the test that does so uses multiprocessing.
128+
self.stream = helpers.Body(self.stream, self.content_length)
129+
130+
# TODO(kgriffs): Use the nocover pragma only for the six.PY3 if..else
112131
def log_error(self, message): # pragma: no cover
113132
"""Log an error to wsgi.error
114133

falcon/request_helpers.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,53 @@ def parse_headers(env):
9494
headers['HOST'] = host
9595

9696
return headers
97+
98+
99+
class Body(object):
100+
"""Wrap wsgi.input streams to make them more robust.
101+
102+
The socket._fileobject and io.BufferedReader are sometimes used
103+
to implement wsgi.input. However, app developers are often burned
104+
by the fact that the read() method for these objects block
105+
indefinitely if either no size is passed, or a size greater than
106+
the request's content length is passed to the method.
107+
108+
This class normalizes wsgi.input behavior between WSGI servers
109+
by implementing non-blocking behavior for the cases mentioned
110+
above.
111+
"""
112+
113+
def __init__(self, stream, stream_len):
114+
"""Initialize the request body instance.
115+
116+
Args:
117+
stream: Instance of socket._fileobject from environ['wsgi.input']
118+
stream_len: Expected content length of the stream.
119+
"""
120+
121+
self.stream = stream
122+
self.stream_len = stream_len
123+
124+
def _make_stream_reader(func):
125+
def read(size=None):
126+
if size is None or size > self.stream_len:
127+
size = self.stream_len
128+
129+
return func(size)
130+
131+
return read
132+
133+
# NOTE(kgriffs): All of the wrapped methods take a single argument,
134+
# which is a size AKA length AKA limit, always in bytes/characters.
135+
# This is consistent with Gunicorn's "Body" class.
136+
for attr in ('read', 'readline', 'readlines'):
137+
target = getattr(self.stream, attr)
138+
setattr(self, attr, _make_stream_reader(target))
139+
140+
def __iter__(self):
141+
return self
142+
143+
def __next__(self):
144+
return next(self.stream)
145+
146+
next = __next__

falcon/tests/dump_wsgi.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ def application(environ, start_response):
1111

1212
body += '}\n\n'
1313

14-
return [body]
14+
return [body.encode('utf-8')]
1515

1616
app = application
1717

falcon/tests/test_request_body.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
1+
import io
2+
import multiprocessing
3+
from wsgiref import simple_server
4+
5+
import requests
6+
7+
import falcon
8+
from falcon import request_helpers
19
import falcon.testing as testing
210

11+
SIZE_1_KB = 1024
12+
313

414
class TestRequestBody(testing.TestBase):
515

@@ -25,8 +35,17 @@ def test_tiny_body(self):
2535
stream.seek(0, 2)
2636
self.assertEquals(stream.tell(), 1)
2737

38+
def test_tiny_body_overflow(self):
39+
expected_body = '.'
40+
self.simulate_request('', body=expected_body)
41+
stream = self.resource.req.stream
42+
43+
# Read too many bytes; shouldn't block
44+
actual_body = stream.read(len(expected_body) + 1)
45+
self.assertEquals(actual_body, expected_body.encode('utf-8'))
46+
2847
def test_read_body(self):
29-
expected_body = testing.rand_string(2, 1 * 1024 * 1024)
48+
expected_body = testing.rand_string(SIZE_1_KB / 2, SIZE_1_KB)
3049
expected_len = len(expected_body)
3150
headers = {'Content-Length': str(expected_len)}
3251

@@ -44,3 +63,85 @@ def test_read_body(self):
4463
self.assertEquals(stream.tell(), expected_len)
4564

4665
self.assertEquals(stream.tell(), expected_len)
66+
67+
def test_read_socket_body(self):
68+
expected_body = testing.rand_string(SIZE_1_KB / 2, SIZE_1_KB)
69+
70+
def server():
71+
class Echo(object):
72+
def on_post(self, req, resp):
73+
# wsgiref socket._fileobject blocks when len not given,
74+
# but Falcon is smarter than that. :D
75+
body = req.stream.read()
76+
resp.body = body
77+
78+
def on_put(self, req, resp):
79+
# wsgiref socket._fileobject blocks when len too long,
80+
# but Falcon should work around that for me.
81+
body = req.stream.read(req.content_length + 1)
82+
resp.body = body
83+
84+
api = falcon.API()
85+
api.add_route('/echo', Echo())
86+
87+
httpd = simple_server.make_server('127.0.0.1', 8989, api)
88+
httpd.serve_forever()
89+
90+
process = multiprocessing.Process(target=server)
91+
process.daemon = True
92+
process.start()
93+
94+
# Let it boot
95+
process.join(1)
96+
97+
url = 'http://127.0.0.1:8989/echo'
98+
resp = requests.post(url, data=expected_body)
99+
self.assertEquals(resp.text, expected_body)
100+
101+
resp = requests.put(url, data=expected_body)
102+
self.assertEquals(resp.text, expected_body)
103+
104+
process.terminate()
105+
106+
def test_body_stream_wrapper(self):
107+
data = testing.rand_string(SIZE_1_KB / 2, SIZE_1_KB)
108+
expected_body = data.encode('utf-8')
109+
expected_len = len(expected_body)
110+
111+
# NOTE(kgriffs): Append newline char to each line
112+
# to match readlines behavior
113+
expected_lines = [(line + '\n').encode('utf-8')
114+
for line in data.split('\n')]
115+
116+
# NOTE(kgriffs): Remove trailing newline to simulate
117+
# what readlines does
118+
expected_lines[-1] = expected_lines[-1][:-1]
119+
120+
stream = io.BytesIO(expected_body)
121+
body = request_helpers.Body(stream, expected_len)
122+
self.assertEquals(body.read(), expected_body)
123+
124+
stream = io.BytesIO(expected_body)
125+
body = request_helpers.Body(stream, expected_len)
126+
self.assertEquals(body.read(2), expected_body[0:2])
127+
128+
stream = io.BytesIO(expected_body)
129+
body = request_helpers.Body(stream, expected_len)
130+
self.assertEquals(body.read(expected_len + 1), expected_body)
131+
132+
stream = io.BytesIO(expected_body)
133+
body = request_helpers.Body(stream, expected_len)
134+
self.assertEquals(body.readline(), expected_lines[0])
135+
136+
stream = io.BytesIO(expected_body)
137+
body = request_helpers.Body(stream, expected_len)
138+
self.assertEquals(body.readlines(), expected_lines)
139+
140+
stream = io.BytesIO(expected_body)
141+
body = request_helpers.Body(stream, expected_len)
142+
self.assertEquals(next(body), expected_lines[0])
143+
144+
stream = io.BytesIO(expected_body)
145+
body = request_helpers.Body(stream, expected_len)
146+
for i, line in enumerate(body):
147+
self.assertEquals(line, expected_lines[i])

tools/test-requires

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
coverage
22
nose
33
ordereddict
4+
requests
45
six
56
testtools

0 commit comments

Comments
 (0)