Allow to disable Transfer-Encoding: chunked (#1560)
Allow to disable Transfer-Encoding: chunked
This commit is contained in:
commit
ef6d78c580
|
@ -60,6 +60,8 @@ async def index(request):
|
||||||
return response.stream(streaming_fn, content_type='text/plain')
|
return response.stream(streaming_fn, content_type='text/plain')
|
||||||
```
|
```
|
||||||
|
|
||||||
|
See [Streaming](streaming.md) for more information.
|
||||||
|
|
||||||
## File Streaming
|
## File Streaming
|
||||||
For large files, a combination of File and Streaming above
|
For large files, a combination of File and Streaming above
|
||||||
```python
|
```python
|
||||||
|
|
|
@ -117,3 +117,27 @@ async def index(request):
|
||||||
|
|
||||||
return stream(stream_from_db)
|
return stream(stream_from_db)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If a client supports HTTP/1.1, Sanic will use [chunked transfer encoding](https://en.wikipedia.org/wiki/Chunked_transfer_encoding); you can explicitly enable or disable it using `chunked` option of the `stream` function.
|
||||||
|
|
||||||
|
## File Streaming
|
||||||
|
|
||||||
|
Sanic provides `sanic.response.file_stream` function that is useful when you want to send a large file. It returns a `StreamingHTTPResponse` object and will use chunked transfer encoding by default; for this reason Sanic doesn't add `Content-Length` HTTP header in the response. If you want to use this header, you can disable chunked transfer encoding and add it manually:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from aiofiles import os as async_os
|
||||||
|
from sanic.response import file_stream
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
async def index(request):
|
||||||
|
file_path = "/srv/www/whatever.png"
|
||||||
|
|
||||||
|
file_stat = await async_os.stat(file_path)
|
||||||
|
headers = {"Content-Length": str(file_stat.st_size)}
|
||||||
|
|
||||||
|
return await file_stream(
|
||||||
|
file_path,
|
||||||
|
headers=headers,
|
||||||
|
chunked=False,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
|
@ -59,16 +59,23 @@ class StreamingHTTPResponse(BaseHTTPResponse):
|
||||||
"status",
|
"status",
|
||||||
"content_type",
|
"content_type",
|
||||||
"headers",
|
"headers",
|
||||||
|
"chunked",
|
||||||
"_cookies",
|
"_cookies",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, streaming_fn, status=200, headers=None, content_type="text/plain"
|
self,
|
||||||
|
streaming_fn,
|
||||||
|
status=200,
|
||||||
|
headers=None,
|
||||||
|
content_type="text/plain",
|
||||||
|
chunked=True,
|
||||||
):
|
):
|
||||||
self.content_type = content_type
|
self.content_type = content_type
|
||||||
self.streaming_fn = streaming_fn
|
self.streaming_fn = streaming_fn
|
||||||
self.status = status
|
self.status = status
|
||||||
self.headers = CIMultiDict(headers or {})
|
self.headers = CIMultiDict(headers or {})
|
||||||
|
self.chunked = chunked
|
||||||
self._cookies = None
|
self._cookies = None
|
||||||
|
|
||||||
async def write(self, data):
|
async def write(self, data):
|
||||||
|
@ -79,7 +86,10 @@ class StreamingHTTPResponse(BaseHTTPResponse):
|
||||||
if type(data) != bytes:
|
if type(data) != bytes:
|
||||||
data = self._encode_body(data)
|
data = self._encode_body(data)
|
||||||
|
|
||||||
self.protocol.push_data(b"%x\r\n%b\r\n" % (len(data), data))
|
if self.chunked:
|
||||||
|
self.protocol.push_data(b"%x\r\n%b\r\n" % (len(data), data))
|
||||||
|
else:
|
||||||
|
self.protocol.push_data(data)
|
||||||
await self.protocol.drain()
|
await self.protocol.drain()
|
||||||
|
|
||||||
async def stream(
|
async def stream(
|
||||||
|
@ -88,6 +98,8 @@ class StreamingHTTPResponse(BaseHTTPResponse):
|
||||||
"""Streams headers, runs the `streaming_fn` callback that writes
|
"""Streams headers, runs the `streaming_fn` callback that writes
|
||||||
content to the response body, then finalizes the response body.
|
content to the response body, then finalizes the response body.
|
||||||
"""
|
"""
|
||||||
|
if version != "1.1":
|
||||||
|
self.chunked = False
|
||||||
headers = self.get_headers(
|
headers = self.get_headers(
|
||||||
version,
|
version,
|
||||||
keep_alive=keep_alive,
|
keep_alive=keep_alive,
|
||||||
|
@ -96,7 +108,8 @@ class StreamingHTTPResponse(BaseHTTPResponse):
|
||||||
self.protocol.push_data(headers)
|
self.protocol.push_data(headers)
|
||||||
await self.protocol.drain()
|
await self.protocol.drain()
|
||||||
await self.streaming_fn(self)
|
await self.streaming_fn(self)
|
||||||
self.protocol.push_data(b"0\r\n\r\n")
|
if self.chunked:
|
||||||
|
self.protocol.push_data(b"0\r\n\r\n")
|
||||||
# no need to await drain here after this write, because it is the
|
# no need to await drain here after this write, because it is the
|
||||||
# very last thing we write and nothing needs to wait for it.
|
# very last thing we write and nothing needs to wait for it.
|
||||||
|
|
||||||
|
@ -109,8 +122,9 @@ class StreamingHTTPResponse(BaseHTTPResponse):
|
||||||
if keep_alive and keep_alive_timeout is not None:
|
if keep_alive and keep_alive_timeout is not None:
|
||||||
timeout_header = b"Keep-Alive: %d\r\n" % keep_alive_timeout
|
timeout_header = b"Keep-Alive: %d\r\n" % keep_alive_timeout
|
||||||
|
|
||||||
self.headers["Transfer-Encoding"] = "chunked"
|
if self.chunked and version == "1.1":
|
||||||
self.headers.pop("Content-Length", None)
|
self.headers["Transfer-Encoding"] = "chunked"
|
||||||
|
self.headers.pop("Content-Length", None)
|
||||||
self.headers["Content-Type"] = self.headers.get(
|
self.headers["Content-Type"] = self.headers.get(
|
||||||
"Content-Type", self.content_type
|
"Content-Type", self.content_type
|
||||||
)
|
)
|
||||||
|
@ -327,6 +341,7 @@ async def file_stream(
|
||||||
mime_type=None,
|
mime_type=None,
|
||||||
headers=None,
|
headers=None,
|
||||||
filename=None,
|
filename=None,
|
||||||
|
chunked=True,
|
||||||
_range=None,
|
_range=None,
|
||||||
):
|
):
|
||||||
"""Return a streaming response object with file data.
|
"""Return a streaming response object with file data.
|
||||||
|
@ -336,6 +351,7 @@ async def file_stream(
|
||||||
:param mime_type: Specific mime_type.
|
:param mime_type: Specific mime_type.
|
||||||
:param headers: Custom Headers.
|
:param headers: Custom Headers.
|
||||||
:param filename: Override filename.
|
:param filename: Override filename.
|
||||||
|
:param chunked: Enable or disable chunked transfer-encoding
|
||||||
:param _range:
|
:param _range:
|
||||||
"""
|
"""
|
||||||
headers = headers or {}
|
headers = headers or {}
|
||||||
|
@ -383,6 +399,7 @@ async def file_stream(
|
||||||
status=status,
|
status=status,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
content_type=mime_type,
|
content_type=mime_type,
|
||||||
|
chunked=chunked,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -391,6 +408,7 @@ def stream(
|
||||||
status=200,
|
status=200,
|
||||||
headers=None,
|
headers=None,
|
||||||
content_type="text/plain; charset=utf-8",
|
content_type="text/plain; charset=utf-8",
|
||||||
|
chunked=True,
|
||||||
):
|
):
|
||||||
"""Accepts an coroutine `streaming_fn` which can be used to
|
"""Accepts an coroutine `streaming_fn` which can be used to
|
||||||
write chunks to a streaming response. Returns a `StreamingHTTPResponse`.
|
write chunks to a streaming response. Returns a `StreamingHTTPResponse`.
|
||||||
|
@ -409,9 +427,14 @@ def stream(
|
||||||
writes content to that response.
|
writes content to that response.
|
||||||
:param mime_type: Specific mime_type.
|
:param mime_type: Specific mime_type.
|
||||||
:param headers: Custom Headers.
|
:param headers: Custom Headers.
|
||||||
|
:param chunked: Enable or disable chunked transfer-encoding
|
||||||
"""
|
"""
|
||||||
return StreamingHTTPResponse(
|
return StreamingHTTPResponse(
|
||||||
streaming_fn, headers=headers, content_type=content_type, status=status
|
streaming_fn,
|
||||||
|
headers=headers,
|
||||||
|
content_type=content_type,
|
||||||
|
status=status,
|
||||||
|
chunked=chunked,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -192,22 +192,59 @@ def test_no_content(json_app):
|
||||||
def streaming_app(app):
|
def streaming_app(app):
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
async def test(request):
|
async def test(request):
|
||||||
return stream(sample_streaming_fn, content_type="text/csv")
|
return stream(
|
||||||
|
sample_streaming_fn,
|
||||||
|
headers={"Content-Length": "7"},
|
||||||
|
content_type="text/csv",
|
||||||
|
)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def test_streaming_adds_correct_headers(streaming_app):
|
@pytest.fixture
|
||||||
|
def non_chunked_streaming_app(app):
|
||||||
|
@app.route("/")
|
||||||
|
async def test(request):
|
||||||
|
return stream(
|
||||||
|
sample_streaming_fn,
|
||||||
|
headers={"Content-Length": "7"},
|
||||||
|
content_type="text/csv",
|
||||||
|
chunked=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_chunked_streaming_adds_correct_headers(streaming_app):
|
||||||
request, response = streaming_app.test_client.get("/")
|
request, response = streaming_app.test_client.get("/")
|
||||||
assert response.headers["Transfer-Encoding"] == "chunked"
|
assert response.headers["Transfer-Encoding"] == "chunked"
|
||||||
assert response.headers["Content-Type"] == "text/csv"
|
assert response.headers["Content-Type"] == "text/csv"
|
||||||
|
# Content-Length is not allowed by HTTP/1.1 specification
|
||||||
|
# when "Transfer-Encoding: chunked" is used
|
||||||
|
assert "Content-Length" not in response.headers
|
||||||
|
|
||||||
|
|
||||||
def test_streaming_returns_correct_content(streaming_app):
|
def test_chunked_streaming_returns_correct_content(streaming_app):
|
||||||
request, response = streaming_app.test_client.get("/")
|
request, response = streaming_app.test_client.get("/")
|
||||||
assert response.text == "foo,bar"
|
assert response.text == "foo,bar"
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_chunked_streaming_adds_correct_headers(
|
||||||
|
non_chunked_streaming_app
|
||||||
|
):
|
||||||
|
request, response = non_chunked_streaming_app.test_client.get("/")
|
||||||
|
assert "Transfer-Encoding" not in response.headers
|
||||||
|
assert response.headers["Content-Type"] == "text/csv"
|
||||||
|
assert response.headers["Content-Length"] == "7"
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_chunked_streaming_returns_correct_content(
|
||||||
|
non_chunked_streaming_app
|
||||||
|
):
|
||||||
|
request, response = non_chunked_streaming_app.test_client.get("/")
|
||||||
|
assert response.text == "foo,bar"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("status", [200, 201, 400, 401])
|
@pytest.mark.parametrize("status", [200, 201, 400, 401])
|
||||||
def test_stream_response_status_returns_correct_headers(status):
|
def test_stream_response_status_returns_correct_headers(status):
|
||||||
response = StreamingHTTPResponse(sample_streaming_fn, status=status)
|
response = StreamingHTTPResponse(sample_streaming_fn, status=status)
|
||||||
|
@ -227,13 +264,27 @@ def test_stream_response_keep_alive_returns_correct_headers(
|
||||||
assert b"Keep-Alive: %s\r\n" % str(keep_alive_timeout).encode() in headers
|
assert b"Keep-Alive: %s\r\n" % str(keep_alive_timeout).encode() in headers
|
||||||
|
|
||||||
|
|
||||||
def test_stream_response_includes_chunked_header():
|
def test_stream_response_includes_chunked_header_http11():
|
||||||
response = StreamingHTTPResponse(sample_streaming_fn)
|
response = StreamingHTTPResponse(sample_streaming_fn)
|
||||||
headers = response.get_headers()
|
headers = response.get_headers(version="1.1")
|
||||||
assert b"Transfer-Encoding: chunked\r\n" in headers
|
assert b"Transfer-Encoding: chunked\r\n" in headers
|
||||||
|
|
||||||
|
|
||||||
def test_stream_response_writes_correct_content_to_transport(streaming_app):
|
def test_stream_response_does_not_include_chunked_header_http10():
|
||||||
|
response = StreamingHTTPResponse(sample_streaming_fn)
|
||||||
|
headers = response.get_headers(version="1.0")
|
||||||
|
assert b"Transfer-Encoding: chunked\r\n" not in headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_response_does_not_include_chunked_header_if_disabled():
|
||||||
|
response = StreamingHTTPResponse(sample_streaming_fn, chunked=False)
|
||||||
|
headers = response.get_headers(version="1.1")
|
||||||
|
assert b"Transfer-Encoding: chunked\r\n" not in headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_response_writes_correct_content_to_transport_when_chunked(
|
||||||
|
streaming_app
|
||||||
|
):
|
||||||
response = StreamingHTTPResponse(sample_streaming_fn)
|
response = StreamingHTTPResponse(sample_streaming_fn)
|
||||||
response.protocol = MagicMock(HttpProtocol)
|
response.protocol = MagicMock(HttpProtocol)
|
||||||
response.protocol.transport = MagicMock(asyncio.Transport)
|
response.protocol.transport = MagicMock(asyncio.Transport)
|
||||||
|
@ -262,6 +313,42 @@ def test_stream_response_writes_correct_content_to_transport(streaming_app):
|
||||||
b"0\r\n\r\n"
|
b"0\r\n\r\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert len(response.protocol.transport.write.call_args_list) == 4
|
||||||
|
|
||||||
|
app.stop()
|
||||||
|
|
||||||
|
streaming_app.run(host=HOST, port=PORT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_response_writes_correct_content_to_transport_when_not_chunked(
|
||||||
|
streaming_app,
|
||||||
|
):
|
||||||
|
response = StreamingHTTPResponse(sample_streaming_fn)
|
||||||
|
response.protocol = MagicMock(HttpProtocol)
|
||||||
|
response.protocol.transport = MagicMock(asyncio.Transport)
|
||||||
|
|
||||||
|
async def mock_drain():
|
||||||
|
pass
|
||||||
|
|
||||||
|
def mock_push_data(data):
|
||||||
|
response.protocol.transport.write(data)
|
||||||
|
|
||||||
|
response.protocol.push_data = mock_push_data
|
||||||
|
response.protocol.drain = mock_drain
|
||||||
|
|
||||||
|
@streaming_app.listener("after_server_start")
|
||||||
|
async def run_stream(app, loop):
|
||||||
|
await response.stream(version="1.0")
|
||||||
|
assert response.protocol.transport.write.call_args_list[1][0][0] == (
|
||||||
|
b"foo,"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.protocol.transport.write.call_args_list[2][0][0] == (
|
||||||
|
b"bar"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(response.protocol.transport.write.call_args_list) == 3
|
||||||
|
|
||||||
app.stop()
|
app.stop()
|
||||||
|
|
||||||
streaming_app.run(host=HOST, port=PORT)
|
streaming_app.run(host=HOST, port=PORT)
|
||||||
|
|
Loading…
Reference in New Issue
Block a user