Skip to content

Commit 54b1498

Browse files
Vicente RuizVicente Ruiz
authored andcommitted
Merge upstream PR pgjones#332 from pgjones/hypercorn: Fix WSGI Violations
Original PR: pgjones#332
2 parents 08dee02 + 4250416 commit 54b1498

3 files changed

Lines changed: 403 additions & 29 deletions

File tree

src/hypercorn/app_wrappers.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,16 +84,31 @@ async def handle_http(
8484
await send({"type": "http.response.body", "body": b"", "more_body": False})
8585

8686
def run_app(self, environ: dict, send: Callable) -> None:
87-
headers: list[tuple[bytes, bytes]]
87+
headers: list[tuple[bytes, bytes]] = []
8888
response_started = False
89+
headers_sent = False
8990
status_code: int | None = None
9091

9192
def start_response(
9293
status: str,
9394
response_headers: list[tuple[str, str]],
9495
exc_info: Exception | None = None,
9596
) -> None:
96-
nonlocal headers, response_started, status_code
97+
nonlocal headers, response_started, status_code, headers_sent
98+
99+
if response_started and exc_info is None:
100+
raise RuntimeError(
101+
"start_response cannot be called again without the exc_info parameter"
102+
)
103+
elif exc_info is not None:
104+
try:
105+
if headers_sent:
106+
# The headers have already been sent and we can no longer change
107+
# the status_code and headers. reraise this exception in accordance
108+
# with the WSGI specification.
109+
raise exc_info[1].with_traceback(exc_info[2])
110+
finally:
111+
exc_info = None # Delete reference to exc_info to avoid circular references
97112

98113
raw, _ = status.split(" ", 1)
99114
status_code = int(raw)
@@ -106,16 +121,35 @@ def start_response(
106121
response_body = self.app(environ, start_response)
107122

108123
try:
109-
first_chunk = True
110124
for output in response_body:
111-
if first_chunk:
125+
# Per the WSGI specification in PEP-3333, the start_response callable
126+
# must not actually transmit the response headers. Instead, it must
127+
# store them for the server to transmit only after the first iteration
128+
# of the application return value that yields a non-empty bytestring.
129+
#
130+
# We therefore delay sending the http.response.start event until after
131+
# we receive a non-empty byte string from the application return value.
132+
if output and not headers_sent:
112133
if not response_started:
113134
raise RuntimeError("WSGI app did not call start_response")
114135

136+
# Send the http.response.start event with the status and headers, flagging
137+
# that this was completed so they aren't sent twice.
115138
send({"type": "http.response.start", "status": status_code, "headers": headers})
116-
first_chunk = False
139+
headers_sent = True
117140

118141
send({"type": "http.response.body", "body": output, "more_body": True})
142+
143+
# If we still haven't sent the headers by this point, then we received no
144+
# non-empty byte strings from the application return value. This can happen when
145+
# handling certain HTTP methods that don't include a response body like HEAD.
146+
# In those cases we still need to send the http.response.start event with the
147+
# status code and headers, but we need to ensure they haven't been sent previously.
148+
if not headers_sent:
149+
if not response_started:
150+
raise RuntimeError("WSGI app did not call start_response")
151+
152+
send({"type": "http.response.start", "status": status_code, "headers": headers})
119153
finally:
120154
if hasattr(response_body, "close"):
121155
response_body.close()

tests/test_app_wrappers.py

Lines changed: 171 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,22 @@
1010

1111
from hypercorn.app_wrappers import _build_environ, InvalidPathError, WSGIWrapper
1212
from hypercorn.typing import ASGIReceiveEvent, ASGISendEvent, ConnectionState, HTTPScope
13-
14-
15-
def echo_body(environ: dict, start_response: Callable) -> list[bytes]:
16-
status = "200 OK"
17-
output = environ["wsgi.input"].read()
18-
headers = [
19-
("Content-Type", "text/plain; charset=utf-8"),
20-
("Content-Length", str(len(output))),
21-
]
22-
start_response(status, headers)
23-
return [output]
13+
from .wsgi_applications import (
14+
wsgi_app_echo_body,
15+
wsgi_app_generator,
16+
wsgi_app_generator_delayed_start_response,
17+
wsgi_app_generator_multiple_start_response_after_body,
18+
wsgi_app_generator_no_body,
19+
wsgi_app_multiple_start_response_no_exc_info,
20+
wsgi_app_no_body,
21+
wsgi_app_no_start_response,
22+
wsgi_app_simple,
23+
)
2424

2525

2626
@pytest.mark.trio
2727
async def test_wsgi_trio() -> None:
28-
app = WSGIWrapper(echo_body, 2**16)
28+
app = WSGIWrapper(wsgi_app_echo_body, 2**16)
2929
scope: HTTPScope = {
3030
"http_version": "1.1",
3131
"asgi": {},
@@ -52,12 +52,12 @@ async def _send(message: ASGISendEvent) -> None:
5252

5353
await app(scope, receive_channel.receive, _send, trio.to_thread.run_sync, trio.from_thread.run)
5454
assert messages == [
55+
{"body": bytearray(b""), "type": "http.response.body", "more_body": True},
5556
{
5657
"headers": [(b"content-type", b"text/plain; charset=utf-8"), (b"content-length", b"0")],
5758
"status": 200,
5859
"type": "http.response.start",
5960
},
60-
{"body": bytearray(b""), "type": "http.response.body", "more_body": True},
6161
{"body": bytearray(b""), "type": "http.response.body", "more_body": False},
6262
]
6363

@@ -83,7 +83,7 @@ def _call_soon(func: Callable, *args: Any) -> Any:
8383

8484
@pytest.mark.asyncio
8585
async def test_wsgi_asyncio() -> None:
86-
app = WSGIWrapper(echo_body, 2**16)
86+
app = WSGIWrapper(wsgi_app_echo_body, 2**16)
8787
scope: HTTPScope = {
8888
"http_version": "1.1",
8989
"asgi": {},
@@ -100,21 +100,24 @@ async def test_wsgi_asyncio() -> None:
100100
"extensions": {},
101101
"state": ConnectionState({}),
102102
}
103-
messages = await _run_app(app, scope)
103+
messages = await _run_app(app, scope, b"Hello, world!")
104104
assert messages == [
105105
{
106-
"headers": [(b"content-type", b"text/plain; charset=utf-8"), (b"content-length", b"0")],
106+
"headers": [
107+
(b"content-type", b"text/plain; charset=utf-8"),
108+
(b"content-length", b"13"),
109+
],
107110
"status": 200,
108111
"type": "http.response.start",
109112
},
110-
{"body": bytearray(b""), "type": "http.response.body", "more_body": True},
111-
{"body": bytearray(b""), "type": "http.response.body", "more_body": False},
113+
{"body": b"Hello, world!", "type": "http.response.body", "more_body": True},
114+
{"body": b"", "type": "http.response.body", "more_body": False},
112115
]
113116

114117

115118
@pytest.mark.asyncio
116119
async def test_max_body_size() -> None:
117-
app = WSGIWrapper(echo_body, 4)
120+
app = WSGIWrapper(wsgi_app_echo_body, 4)
118121
scope: HTTPScope = {
119122
"http_version": "1.1",
120123
"asgi": {},
@@ -138,13 +141,9 @@ async def test_max_body_size() -> None:
138141
]
139142

140143

141-
def no_start_response(environ: dict, start_response: Callable) -> list[bytes]:
142-
return [b"result"]
143-
144-
145144
@pytest.mark.asyncio
146145
async def test_no_start_response() -> None:
147-
app = WSGIWrapper(no_start_response, 2**16)
146+
app = WSGIWrapper(wsgi_app_no_start_response, 2**16)
148147
scope: HTTPScope = {
149148
"http_version": "1.1",
150149
"asgi": {},
@@ -206,3 +205,151 @@ def test_build_environ_root_path() -> None:
206205
}
207206
with pytest.raises(InvalidPathError):
208207
_build_environ(scope, b"")
208+
209+
210+
@pytest.mark.asyncio
211+
@pytest.mark.parametrize("wsgi_app", [wsgi_app_simple, wsgi_app_generator])
212+
async def test_wsgi_protocol(wsgi_app: Callable) -> None:
213+
app = WSGIWrapper(wsgi_app, 2**16)
214+
scope: HTTPScope = {
215+
"http_version": "1.1",
216+
"asgi": {},
217+
"method": "GET",
218+
"headers": [],
219+
"path": "/",
220+
"root_path": "/",
221+
"query_string": b"a=b",
222+
"raw_path": b"/",
223+
"scheme": "http",
224+
"type": "http",
225+
"client": ("localhost", 80),
226+
"server": None,
227+
"extensions": {},
228+
"state": ConnectionState({}),
229+
}
230+
231+
messages = await _run_app(app, scope)
232+
assert messages == [
233+
{
234+
"headers": [(b"x-test-header", b"Test-Value")],
235+
"status": 200,
236+
"type": "http.response.start",
237+
},
238+
{"body": b"Hello, ", "type": "http.response.body", "more_body": True},
239+
{"body": b"world!", "type": "http.response.body", "more_body": True},
240+
{"body": b"", "type": "http.response.body", "more_body": False},
241+
]
242+
243+
244+
@pytest.mark.asyncio
245+
@pytest.mark.parametrize("wsgi_app", [wsgi_app_no_body, wsgi_app_generator_no_body])
246+
async def test_wsgi_protocol_no_body(wsgi_app: Callable) -> None:
247+
app = WSGIWrapper(wsgi_app, 2**16)
248+
scope: HTTPScope = {
249+
"http_version": "1.1",
250+
"asgi": {},
251+
"method": "GET",
252+
"headers": [],
253+
"path": "/",
254+
"root_path": "/",
255+
"query_string": b"a=b",
256+
"raw_path": b"/",
257+
"scheme": "http",
258+
"type": "http",
259+
"client": ("localhost", 80),
260+
"server": None,
261+
"extensions": {},
262+
"state": ConnectionState({}),
263+
}
264+
265+
messages = await _run_app(app, scope)
266+
assert messages == [
267+
{
268+
"headers": [(b"x-test-header", b"Test-Value")],
269+
"status": 200,
270+
"type": "http.response.start",
271+
},
272+
{"body": b"", "type": "http.response.body", "more_body": False},
273+
]
274+
275+
276+
@pytest.mark.asyncio
277+
async def test_wsgi_protocol_overwrite_start_response() -> None:
278+
app = WSGIWrapper(wsgi_app_generator_delayed_start_response, 2**16)
279+
scope: HTTPScope = {
280+
"http_version": "1.1",
281+
"asgi": {},
282+
"method": "GET",
283+
"headers": [],
284+
"path": "/",
285+
"root_path": "/",
286+
"query_string": b"a=b",
287+
"raw_path": b"/",
288+
"scheme": "http",
289+
"type": "http",
290+
"client": ("localhost", 80),
291+
"server": None,
292+
"extensions": {},
293+
"state": ConnectionState({}),
294+
}
295+
296+
messages = await _run_app(app, scope)
297+
assert messages == [
298+
{"body": b"", "type": "http.response.body", "more_body": True},
299+
{
300+
"headers": [(b"x-test-header", b"New-Value")],
301+
"status": 500,
302+
"type": "http.response.start",
303+
},
304+
{"body": b"Hello, ", "type": "http.response.body", "more_body": True},
305+
{"body": b"world!", "type": "http.response.body", "more_body": True},
306+
{"body": b"", "type": "http.response.body", "more_body": False},
307+
]
308+
309+
310+
@pytest.mark.asyncio
311+
async def test_wsgi_protocol_multiple_start_response_no_exc_info() -> None:
312+
app = WSGIWrapper(wsgi_app_multiple_start_response_no_exc_info, 2**16)
313+
scope: HTTPScope = {
314+
"http_version": "1.1",
315+
"asgi": {},
316+
"method": "GET",
317+
"headers": [],
318+
"path": "/",
319+
"root_path": "/",
320+
"query_string": b"a=b",
321+
"raw_path": b"/",
322+
"scheme": "http",
323+
"type": "http",
324+
"client": ("localhost", 80),
325+
"server": None,
326+
"extensions": {},
327+
"state": ConnectionState({}),
328+
}
329+
330+
with pytest.raises(RuntimeError):
331+
await _run_app(app, scope)
332+
333+
334+
@pytest.mark.asyncio
335+
async def test_wsgi_protocol_multiple_start_response_after_body() -> None:
336+
app = WSGIWrapper(wsgi_app_generator_multiple_start_response_after_body, 2**16)
337+
scope: HTTPScope = {
338+
"http_version": "1.1",
339+
"asgi": {},
340+
"method": "GET",
341+
"headers": [],
342+
"path": "/",
343+
"root_path": "/",
344+
"query_string": b"a=b",
345+
"raw_path": b"/",
346+
"scheme": "http",
347+
"type": "http",
348+
"client": ("localhost", 80),
349+
"server": None,
350+
"extensions": {},
351+
"state": ConnectionState({}),
352+
}
353+
354+
with pytest.raises(ValueError):
355+
await _run_app(app, scope)

0 commit comments

Comments
 (0)