Skip to content

Commit 6e459f0

Browse files
authored
Merge branch 'main' into feat/contrib-msgspec-cleanup
2 parents 2ddfe4b + d63f917 commit 6e459f0

9 files changed

Lines changed: 179 additions & 56 deletions

File tree

docs/usage/middleware/builtin-middleware.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,15 +232,15 @@ You can configure the following additional brotli-specific values:
232232
Zstd
233233
^^^^^^
234234

235-
The `zstandard <https://pypi.org/project/zstandard>`_ package is required to run this middleware. It is available as an extra for Litestar via the ``zstd`` extra: (``pip install 'litestar[zstd]'``).
235+
The `backports.zstd <https://pypi.org/project/backports.zstd/>`_ package is required to run this middleware. It is available as an extra for Litestar via the ``zstd`` extra: (``pip install 'litestar[zstd]'``).
236236

237237
You can enable zstd compression of responses by passing an instance of
238238
:class:`~litestar.config.compression.CompressionConfig` with the ``backend`` parameter set to ``"zstd"``.
239239

240240
You can configure the following additional zstd-specific values:
241241

242242
* ``minimum_size``: the minimum threshold for response size to enable compression. Smaller responses will not be compressed. Default is 500, i.e. half a kilobyte.
243-
* ``zstd_compress_level``: Range [0-22], Controls the compression level. Higher values increase compression ratio but are slower. Default is 3.
243+
* ``zstd_compress_level``: Integer >= 0. Controls the compression level. Higher values increase compression ratio but are slower. A value of 0 indicates use of the library default, which is typically level 3. Default is 0.
244244
* ``zstd_gzip_fallback``: Boolean indicating whether to fall back to gzip if Zstd is not supported. Default is True.
245245

246246
.. code-block:: python

docs/usage/requests.rst

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ To disable this limit for a specific handler / router / controller, it can be se
304304
For requests that define a ``Content-Length`` header, Litestar will not attempt to
305305
read the request body should the header value exceed the ``request_max_body_size``.
306306

307-
If the header value is within the allowed bounds, Litestar will verify during the
308-
streaming of the request body that it does not exceed the size specified in the
309-
header. Should the request exceed this size, it will abort the request with a
310-
``400 - Bad Request``.
307+
Validation that the actual request body matches the ``Content-Length`` header is
308+
delegated to the ASGI server (e.g. uvicorn). This ensures compatibility with
309+
request decompression middleware, where the decompressed body may legitimately
310+
exceed the wire-format ``Content-Length``.

litestar/config/compression.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@ class CompressionConfig:
3030
"""Minimum response size (bytes) to enable compression, affects all backends."""
3131
gzip_compress_level: int = field(default=9)
3232
"""Range ``[0-9]``, see :doc:`python:library/gzip`."""
33-
zstd_compress_level: int = field(default=3)
34-
"""Range `[0-22]`, see `zstandard <https://pypi.org/project/zstandard/>`"""
33+
zstd_compress_level: int = field(default=0)
34+
"""Integer greater than or equal to 0.
35+
A value of 0 indicates use of default compression level set by the library.
36+
"""
3537
brotli_quality: int = field(default=5)
3638
"""Range ``[0-11]``, Controls the compression-speed vs compression-density tradeoff.
3739
@@ -86,12 +88,13 @@ def __post_init__(self) -> None:
8688
self.gzip_fallback = self.brotli_gzip_fallback
8789
self.compression_facade = BrotliCompression
8890
elif self.backend == "zstd":
89-
if self.zstd_compress_level < 1 or self.zstd_compress_level > 22:
91+
from litestar.middleware.compression.zstd_facade import ZstdCompression
92+
93+
upper_bound = ZstdCompression.upper_bound
94+
if not (0 <= self.zstd_compress_level <= upper_bound):
9095
raise ImproperlyConfiguredException(
91-
f"zstd_compress_level must be between 1 and 22, given: {self.zstd_compress_level}"
96+
f"zstd_compress_level must be between 0 and {upper_bound}, given: {self.zstd_compress_level}"
9297
)
9398

94-
from litestar.middleware.compression.zstd_facade import ZstdCompression
95-
9699
self.gzip_fallback = self.zstd_gzip_fallback
97100
self.compression_facade = ZstdCompression

litestar/connection/request.py

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -208,23 +208,12 @@ async def stream(self) -> AsyncGenerator[bytes, None]:
208208
if body:
209209
total_bytes_streamed += len(body)
210210

211-
# if a 'content-length' header was set, check if we have
212-
# received more bytes than specified. in most cases this should
213-
# be caught before it hits the application layer and an ASGI
214-
# server (e.g. uvicorn) will not allow this, but since it's not
215-
# forbidden according to the HTTP or ASGI spec, we err on the
216-
# side of caution and still perform this check.
217-
#
218-
# uvicorn documented behaviour for this case:
219-
# https://github.com/encode/uvicorn/blob/fe3910083e3990695bc19c2ef671dd447262ae18/docs/server-behavior.md?plain=1#L11
220-
if announced_content_length:
221-
if total_bytes_streamed > announced_content_length:
222-
raise ClientException("Malformed request")
223-
224-
# we don't have a 'content-length' header, likely a chunked
225-
# transfer. we don't really care and simply check if we have
226-
# received more bytes than allowed
227-
elif total_bytes_streamed > max_content_length:
211+
# enforce the request body size limit on actual bytes
212+
# received. content-length validation is intentionally left to
213+
# the ASGI server (e.g. uvicorn) since middleware such as
214+
# request decompression can legitimately cause the streamed
215+
# body to exceed the wire-format content-length.
216+
if total_bytes_streamed > max_content_length:
228217
raise RequestEntityTooLarge
229218

230219
yield body
Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
from __future__ import annotations
22

3+
import sys
34
from typing import TYPE_CHECKING, Literal
45

56
from litestar.enums import CompressionEncoding
67
from litestar.exceptions import MissingDependencyException
78
from litestar.middleware.compression.facade import CompressionFacade
89

9-
try:
10-
import zstandard as zstd
11-
except ImportError as e:
12-
raise MissingDependencyException("zstandard", extra="zstd") from e
10+
if sys.version_info >= (3, 14):
11+
from compression import zstd
12+
else:
13+
try:
14+
from backports import zstd
15+
except ImportError as e:
16+
raise MissingDependencyException("backports.zstd", extra="zstd") from e
1317

1418
if TYPE_CHECKING:
1519
from io import BytesIO
@@ -18,22 +22,31 @@
1822

1923

2024
class ZstdCompression(CompressionFacade):
21-
__slots__ = ("buffer", "cctx", "compression_encoding", "compressor")
25+
__slots__ = ("buffer", "compression_encoding", "compressor")
2226

2327
encoding = CompressionEncoding("zstd")
24-
25-
def __init__(self, buffer: BytesIO, compression_encoding: Literal["zstd"] | str, config: CompressionConfig) -> None:
28+
upper_bound = zstd.CompressionParameter.compression_level.bounds()[1]
29+
30+
def __init__(
31+
self,
32+
buffer: BytesIO,
33+
compression_encoding: Literal["zstd"] | str,
34+
config: CompressionConfig,
35+
) -> None:
2636
self.buffer = buffer
2737
self.compression_encoding = compression_encoding
28-
self.cctx = zstd.ZstdCompressor(level=config.zstd_compress_level)
29-
self.compressor = self.cctx.stream_writer(buffer)
30-
31-
def write(self, body: bytes | bytearray, final: bool = False) -> None:
32-
self.compressor.write(body)
33-
if final:
34-
self.compressor.flush(zstd.FLUSH_FRAME)
38+
self.compressor = zstd.ZstdCompressor(level=config.zstd_compress_level)
39+
40+
def write(
41+
self,
42+
body: bytes | bytearray,
43+
final: bool = False,
44+
) -> None:
45+
if not final:
46+
self.buffer.write(self.compressor.compress(body, mode=zstd.ZstdCompressor.FLUSH_BLOCK))
3547
else:
36-
self.compressor.flush(zstd.FLUSH_BLOCK)
48+
self.buffer.write(self.compressor.compress(body, mode=zstd.ZstdCompressor.FLUSH_FRAME))
3749

3850
def close(self) -> None:
39-
self.compressor.flush(zstd.FLUSH_FRAME)
51+
if self.compressor.last_mode != zstd.ZstdCompressor.FLUSH_FRAME:
52+
self.buffer.write(self.compressor.flush())

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ annotated-types = ["annotated-types"]
5353
attrs = ["attrs"]
5454
brotli = ["brotli"]
5555
zstd = [
56-
"zstandard>=0.24.0",
56+
"backports-zstd>=1.3.0; python_version < '3.14'",
5757
]
5858
cli = ["jsbeautifier", "uvicorn[standard]"]
5959
cryptography = ["cryptography"]
@@ -193,6 +193,7 @@ test = [
193193
"pytest-timeout",
194194
"pytest-xdist",
195195
"time-machine",
196+
"zstandard",
196197
]
197198

198199
[build-system]

tests/unit/test_connection/test_request.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -538,15 +538,34 @@ async def get_state(request: Request[Any, Any, State]) -> dict[str, str]:
538538
assert response.json() == {"state": 2}
539539

540540

541-
def test_request_body_exceeds_content_length() -> None:
542-
@post("/")
543-
def handler(body: bytes) -> None:
544-
pass
541+
def test_request_body_exceeding_content_length_is_allowed() -> None:
542+
"""Body larger than Content-Length but within request_max_body_size succeeds.
543+
544+
Decompression middleware can legitimately produce a body larger than the
545+
wire-format Content-Length. Content-length enforcement is delegated to the
546+
ASGI server (e.g. uvicorn).
547+
"""
548+
549+
@post("/", request_max_body_size=10)
550+
def handler(body: bytes) -> bytes:
551+
return body
545552

546553
with create_test_client([handler]) as client:
547554
response = client.post("/", headers={"content-length": "1"}, content=b"ab")
548-
assert response.status_code == HTTP_400_BAD_REQUEST
549-
assert response.json() == {"status_code": 400, "detail": "Malformed request"}
555+
assert response.status_code == 201
556+
assert response.content == b"ab"
557+
558+
559+
def test_request_body_exceeding_content_length_still_enforces_max_body_size() -> None:
560+
"""Content-length mismatch is allowed, but request_max_body_size is still enforced."""
561+
562+
@post("/", request_max_body_size=1)
563+
def handler(body: bytes) -> bytes:
564+
return body
565+
566+
with create_test_client([handler]) as client:
567+
response = client.post("/", headers={"content-length": "1"}, content=b"ab")
568+
assert response.status_code == HTTP_413_REQUEST_ENTITY_TOO_LARGE
550569

551570

552571
def test_request_body_exceeds_max_request_body_size() -> None:

tests/unit/test_middleware/test_compression_middleware.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# pyright: reportUnnecessaryTypeIgnoreComment=false
22

3+
import sys
34
import zlib
45
from collections.abc import AsyncIterator, Callable
56
from io import BytesIO
@@ -20,6 +21,12 @@
2021
from litestar.testing import create_test_client
2122
from litestar.types.asgi_types import ASGIApp, HTTPResponseBodyEvent, HTTPResponseStartEvent, Message, Scope
2223

24+
if sys.version_info >= (3, 14):
25+
from compression import zstd
26+
else:
27+
from backports import zstd
28+
zstd_compression_level_upper_bound = zstd.CompressionParameter.compression_level.bounds()[1]
29+
2330
BrotliMode = Literal["text", "generic", "font"]
2431

2532

@@ -165,7 +172,14 @@ def test_config_gzip_compress_level_validation(gzip_compress_level: int, should_
165172

166173

167174
@pytest.mark.parametrize(
168-
"zstd_compress_level, should_raise", ((0, True), (1, False), (22, False), (23, True), (-1, True))
175+
"zstd_compress_level, should_raise",
176+
(
177+
(-1, True),
178+
(0, False),
179+
(1, False),
180+
(zstd_compression_level_upper_bound, False),
181+
(zstd_compression_level_upper_bound + 1, True),
182+
),
169183
)
170184
def test_config_zstd_compress_level_validation(zstd_compress_level: int, should_raise: bool) -> None:
171185
if should_raise:

0 commit comments

Comments
 (0)