Skip to content

Commit 679f287

Browse files
authored
fix(litestar): apply Litestar's request_max_body_size default (#164)
Litestar.from_config() passes every AppConfig field explicitly, so the 10 MB request_max_body_size default that Litestar.__init__ applies never reached the built app and every body-reading handler returned 500: 'request_max_body_size' set to 'Empty' on all layers. _apply_config now fills the field when it is Empty; a caller's own value, including an explicit None for no limit, is untouched and proven enforced by a 413 test. A guard test pins the constant against Litestar's own signature default. The litestar extra floor moves from >=2.9 to >=2.15: AppConfig.request_max_body_size was added in 2.13.0 and litestar.middleware.ASGIMiddleware, already subclassed by the OTel middleware, in 2.15.0, so the declared floor was already unsupported. Upstream: litestar-org/litestar#4296 See planning/changes/2026-08-10.03-litestar-request-max-body-size.md
1 parent e6b6be2 commit 679f287

7 files changed

Lines changed: 120 additions & 3 deletions

File tree

‎architecture/bootstrappers.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ The guard is uniform: the same marker and warning apply to all four app-bearing
8787
frameworks. `attach` is typed `Callable[[], object]` because some hooks (FastStream's
8888
`on_shutdown`) return the callback.
8989

90+
Litestar's `attach` thunk wraps `_apply_config`, which also normalizes the `AppConfig`
91+
it is handed before `Litestar.from_config()` builds the app: it sets `debug` from
92+
`service_debug`, and fills `request_max_body_size` with Litestar's own 10 MB default
93+
when the config leaves it `Empty`. `from_config()` passes every field explicitly, so
94+
the default `Litestar(...)` applies never reaches the app and an unset value 500s
95+
every body-reading handler ([litestar#4296](https://github.com/litestar-org/litestar/issues/4296)).
96+
A caller's own value, including an explicit `None` for no limit, is left alone.
97+
9098
## Single-threaded init (free-threading)
9199

92100
`bootstrap()`/`teardown()` are startup/shutdown, main-thread operations; their

‎docs/integrations/litestar.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,30 @@ LitestarConfig(
119119
prometheus_additional_params={"exclude_unhandled_paths": True},
120120
)
121121
```
122+
123+
## Request body size limit
124+
125+
`LitestarBootstrapper` builds its app with `Litestar.from_config()`, which —
126+
unlike `Litestar(...)` — passes every `AppConfig` field explicitly and so
127+
skips the 10 MB `request_max_body_size` default that `Litestar(...)` applies.
128+
Left as-is, that means every handler that reads a request body returns `500:
129+
'request_max_body_size' set to 'Empty' on all layers`
130+
([litestar#4296](https://github.com/litestar-org/litestar/issues/4296)).
131+
132+
The bootstrapper works around this by filling `request_max_body_size` with
133+
Litestar's own 10 MB default whenever your `AppConfig` leaves it unset. This
134+
is not exposed as a `LitestarConfig` field — set it on your own `AppConfig`
135+
instead, the same way as every other Litestar app-level knob:
136+
137+
```python
138+
from litestar.config.app import AppConfig
139+
140+
LitestarConfig(
141+
service_name="microservice",
142+
application_config=AppConfig(request_max_body_size=5_000_000), # 5 MB limit
143+
)
144+
```
145+
146+
Pass `request_max_body_size=None` for no limit. Any value you set — including
147+
`None` — is left untouched; the bootstrapper only fills it in when it is
148+
unset.

‎lite_bootstrap/bootstrappers/litestar_bootstrapper.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from litestar.openapi.plugins import SwaggerRenderPlugin
3939
from litestar.plugins.structlog import StructlogConfig, StructlogPlugin
4040
from litestar.static_files import create_static_files_router
41+
from litestar.types import Empty
4142

4243
if import_checker.is_litestar_installed and import_checker.is_prometheus_client_installed:
4344
# litestar.plugins.prometheus imports prometheus_client, which the `litestar`
@@ -71,6 +72,11 @@ def build_span_name(method: str, route: str) -> str:
7172
_LOGGING_MIDDLEWARE_REQUEST_LOG_FIELDS: typing.Final = ("path", "method", "content_type", "path_params")
7273
_LOGGING_MIDDLEWARE_RESPONSE_LOG_FIELDS: typing.Final = ("status_code",)
7374

75+
# Litestar.from_config() passes every AppConfig field explicitly, so the default that
76+
# Litestar.__init__ applies never reaches an app built from a config. Pinned to Litestar's
77+
# own default by a guard test. See https://github.com/litestar-org/litestar/issues/4296.
78+
_LITESTAR_DEFAULT_REQUEST_MAX_BODY_SIZE: typing.Final = 10_000_000
79+
7480

7581
def build_litestar_route_details_from_scope(
7682
scope: typing.MutableMapping[str, typing.Any],
@@ -348,6 +354,9 @@ def __init__(self, bootstrap_config: LitestarConfig) -> None:
348354

349355
def _apply_config(self, application_config: "AppConfig") -> None:
350356
application_config.debug = self.bootstrap_config.service_debug
357+
# An Empty value reaches layer resolution and 500s every body-reading handler.
358+
if application_config.request_max_body_size is Empty:
359+
application_config.request_max_body_size = _LITESTAR_DEFAULT_REQUEST_MAX_BODY_SIZE
351360
application_config.on_shutdown.append(self.teardown)
352361

353362
def is_ready(self) -> bool:

‎planning/changes/2026-08-10.03-litestar-request-max-body-size.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
summary: Fill `request_max_body_size` with Litestar's own 10 MB default when the bootstrapped `AppConfig` leaves it `Empty`, so body-reading handlers stop returning 500 under `Litestar.from_config()`, and pin the constant against Litestar's signature so an upstream change fails CI.
2+
summary: `LitestarBootstrapper._apply_config` now fills `request_max_body_size` with Litestar's 10 MB default when the `AppConfig` leaves it `Empty`, so body-reading handlers stop returning 500 under `Litestar.from_config()`; a caller's own value (including `None`) is untouched, and a guard test pins the constant against Litestar's signature default.
33
---
44

55
# Design: Apply Litestar's request_max_body_size default when the AppConfig leaves it unset

‎planning/releases/1.4.0.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,23 @@ Supplying `litestar_logging_middleware_config` while
8181
`litestar_logging_middleware_enabled` is `False` is a no-op that emits a
8282
warning — set the flag to actually turn logging on.
8383

84+
## Bug fixes
85+
86+
- **Litestar apps can read request bodies again.** `Litestar.from_config()` — which
87+
`LitestarBootstrapper` uses — passes every `AppConfig` field explicitly, so the 10 MB
88+
`request_max_body_size` default that `Litestar(...)` applies never reached the built
89+
app. Every handler taking a request body returned
90+
`500: 'request_max_body_size' set to 'Empty' on all layers` unless the caller set the
91+
field themselves. The bootstrapper now fills it when the config leaves it unset; a
92+
caller's own value, including an explicit `None` for no limit, is untouched. Reported
93+
upstream as [litestar#4296](https://github.com/litestar-org/litestar/issues/4296).
94+
- **The `litestar` extra's floor moved from `>=2.9` to `>=2.15`.** `AppConfig.request_max_body_size`,
95+
which the fix above now reads, was only added in litestar 2.13; and
96+
`litestar.middleware.ASGIMiddleware`, which the OpenTelemetry middleware already subclassed
97+
before this release, was only added in litestar 2.15. `>=2.9` was never actually supported for
98+
the OTel path — this just makes the declared floor honest.
99+
84100
## References
85101

86102
- `planning/changes/2026-08-10.01-litestar-middleware-logging.md`
103+
- `planning/changes/2026-08-10.03-litestar-request-max-body-size.md`

‎pyproject.toml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,10 @@ fastapi-all = [
8585
"lite-bootstrap[fastapi-sentry,fastapi-otl,fastapi-logging,fastapi-metrics,pyroscope]",
8686
]
8787
litestar = [
88-
"litestar>=2.9",
88+
# >=2.13 for AppConfig.request_max_body_size (LitestarBootstrapper._apply_config reads it);
89+
# >=2.15 for litestar.middleware.ASGIMiddleware (LitestarOpenTelemetryInstrumentationMiddleware
90+
# subclasses it) -- the higher floor wins.
91+
"litestar>=2.15",
8992
]
9093
litestar-sentry = [
9194
"lite-bootstrap[litestar,sentry]",

‎tests/test_litestar_bootstrap.py‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import contextlib
22
import dataclasses
33
import gc
4+
import inspect
45
import json
56
import logging
67
import sys
@@ -24,6 +25,7 @@
2425

2526
from lite_bootstrap import LitestarBootstrapper, LitestarConfig, import_checker
2627
from lite_bootstrap.bootstrappers.litestar_bootstrapper import (
28+
_LITESTAR_DEFAULT_REQUEST_MAX_BODY_SIZE,
2729
LitestarLoggingInstrument,
2830
LitestarOpenTelemetryInstrumentationMiddleware,
2931
build_litestar_route_details_from_scope,
@@ -322,7 +324,7 @@ def _access_log_records(log_lines: list[str]) -> list[dict[str, typing.Any]]:
322324
def _post_password(config: LitestarConfig) -> list[str]:
323325
"""Bootstrap, POST credentials, and return the log lines Litestar emitted for that request."""
324326

325-
@litestar.post("/login", request_max_body_size=1000)
327+
@litestar.post("/login")
326328
async def _login_handler(data: dict[str, str]) -> dict[str, str]:
327329
return data
328330

@@ -457,3 +459,54 @@ def test_litestar_access_logging_excluded_paths_none_when_all_degenerate(
457459

458460
assert instrument._build_logging_middleware_excluded_paths() == [] # noqa: SLF001
459461
assert instrument._build_logging_middleware_config().exclude is None # noqa: SLF001
462+
463+
464+
def test_litestar_bootstrap_fills_unset_request_max_body_size(litestar_config: LitestarConfig) -> None:
465+
@litestar.post("/echo")
466+
async def echo_handler(data: dict[str, str]) -> dict[str, str]:
467+
return data
468+
469+
config = dataclasses.replace(litestar_config, application_config=AppConfig(route_handlers=[echo_handler]))
470+
application = LitestarBootstrapper(bootstrap_config=config).bootstrap()
471+
472+
with TestClient(app=application) as client:
473+
response = client.post("/echo", json={"key": "value"})
474+
475+
assert response.status_code == status_codes.HTTP_201_CREATED
476+
assert response.json() == {"key": "value"}
477+
assert application.request_max_body_size == _LITESTAR_DEFAULT_REQUEST_MAX_BODY_SIZE
478+
479+
480+
def test_litestar_bootstrap_keeps_explicit_request_max_body_size(litestar_config: LitestarConfig) -> None:
481+
explicit_max_body_size = 42
482+
483+
@litestar.post("/echo")
484+
async def echo_handler(data: dict[str, str]) -> dict[str, str]:
485+
return data # pragma: no cover -- body exceeds the limit before this runs
486+
487+
config = dataclasses.replace(
488+
litestar_config,
489+
application_config=AppConfig(route_handlers=[echo_handler], request_max_body_size=explicit_max_body_size),
490+
)
491+
application = LitestarBootstrapper(bootstrap_config=config).bootstrap()
492+
493+
with TestClient(app=application) as client:
494+
response = client.post("/echo", json={"key": "value" * explicit_max_body_size})
495+
496+
assert response.status_code == status_codes.HTTP_413_REQUEST_ENTITY_TOO_LARGE
497+
assert application.request_max_body_size == explicit_max_body_size
498+
499+
500+
def test_litestar_bootstrap_keeps_explicit_unlimited_request_max_body_size(litestar_config: LitestarConfig) -> None:
501+
config = dataclasses.replace(litestar_config, application_config=AppConfig(request_max_body_size=None))
502+
application = LitestarBootstrapper(bootstrap_config=config).bootstrap()
503+
504+
with TestClient(app=application):
505+
assert application.request_max_body_size is None
506+
507+
508+
def test_litestar_default_request_max_body_size_matches_litestar() -> None:
509+
"""Guard: our constant must stay equal to the default Litestar's own __init__ applies."""
510+
litestar_default = inspect.signature(litestar.Litestar.__init__).parameters["request_max_body_size"].default
511+
512+
assert litestar_default == _LITESTAR_DEFAULT_REQUEST_MAX_BODY_SIZE

0 commit comments

Comments
 (0)