diff --git a/architecture/bootstrappers.md b/architecture/bootstrappers.md index 6c3e436..1adbf21 100644 --- a/architecture/bootstrappers.md +++ b/architecture/bootstrappers.md @@ -87,6 +87,14 @@ The guard is uniform: the same marker and warning apply to all four app-bearing frameworks. `attach` is typed `Callable[[], object]` because some hooks (FastStream's `on_shutdown`) return the callback. +Litestar's `attach` thunk wraps `_apply_config`, which also normalizes the `AppConfig` +it is handed before `Litestar.from_config()` builds the app: it sets `debug` from +`service_debug`, and fills `request_max_body_size` with Litestar's own 10 MB default +when the config leaves it `Empty`. `from_config()` passes every field explicitly, so +the default `Litestar(...)` applies never reaches the app and an unset value 500s +every body-reading handler ([litestar#4296](https://github.com/litestar-org/litestar/issues/4296)). +A caller's own value, including an explicit `None` for no limit, is left alone. + ## Single-threaded init (free-threading) `bootstrap()`/`teardown()` are startup/shutdown, main-thread operations; their diff --git a/docs/integrations/litestar.md b/docs/integrations/litestar.md index 532cd62..cde6486 100644 --- a/docs/integrations/litestar.md +++ b/docs/integrations/litestar.md @@ -119,3 +119,30 @@ LitestarConfig( prometheus_additional_params={"exclude_unhandled_paths": True}, ) ``` + +## Request body size limit + +`LitestarBootstrapper` builds its app with `Litestar.from_config()`, which — +unlike `Litestar(...)` — passes every `AppConfig` field explicitly and so +skips the 10 MB `request_max_body_size` default that `Litestar(...)` applies. +Left as-is, that means every handler that reads a request body returns `500: +'request_max_body_size' set to 'Empty' on all layers` +([litestar#4296](https://github.com/litestar-org/litestar/issues/4296)). + +The bootstrapper works around this by filling `request_max_body_size` with +Litestar's own 10 MB default whenever your `AppConfig` leaves it unset. This +is not exposed as a `LitestarConfig` field — set it on your own `AppConfig` +instead, the same way as every other Litestar app-level knob: + +```python +from litestar.config.app import AppConfig + +LitestarConfig( + service_name="microservice", + application_config=AppConfig(request_max_body_size=5_000_000), # 5 MB limit +) +``` + +Pass `request_max_body_size=None` for no limit. Any value you set — including +`None` — is left untouched; the bootstrapper only fills it in when it is +unset. diff --git a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py index ef6c10a..0226323 100644 --- a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py @@ -38,6 +38,7 @@ from litestar.openapi.plugins import SwaggerRenderPlugin from litestar.plugins.structlog import StructlogConfig, StructlogPlugin from litestar.static_files import create_static_files_router + from litestar.types import Empty if import_checker.is_litestar_installed and import_checker.is_prometheus_client_installed: # litestar.plugins.prometheus imports prometheus_client, which the `litestar` @@ -71,6 +72,11 @@ def build_span_name(method: str, route: str) -> str: _LOGGING_MIDDLEWARE_REQUEST_LOG_FIELDS: typing.Final = ("path", "method", "content_type", "path_params") _LOGGING_MIDDLEWARE_RESPONSE_LOG_FIELDS: typing.Final = ("status_code",) +# Litestar.from_config() passes every AppConfig field explicitly, so the default that +# Litestar.__init__ applies never reaches an app built from a config. Pinned to Litestar's +# own default by a guard test. See https://github.com/litestar-org/litestar/issues/4296. +_LITESTAR_DEFAULT_REQUEST_MAX_BODY_SIZE: typing.Final = 10_000_000 + def build_litestar_route_details_from_scope( scope: typing.MutableMapping[str, typing.Any], @@ -348,6 +354,9 @@ def __init__(self, bootstrap_config: LitestarConfig) -> None: def _apply_config(self, application_config: "AppConfig") -> None: application_config.debug = self.bootstrap_config.service_debug + # An Empty value reaches layer resolution and 500s every body-reading handler. + if application_config.request_max_body_size is Empty: + application_config.request_max_body_size = _LITESTAR_DEFAULT_REQUEST_MAX_BODY_SIZE application_config.on_shutdown.append(self.teardown) def is_ready(self) -> bool: diff --git a/planning/changes/2026-08-10.03-litestar-request-max-body-size.md b/planning/changes/2026-08-10.03-litestar-request-max-body-size.md index c1e9813..47e2d4b 100644 --- a/planning/changes/2026-08-10.03-litestar-request-max-body-size.md +++ b/planning/changes/2026-08-10.03-litestar-request-max-body-size.md @@ -1,5 +1,5 @@ --- -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. +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. --- # Design: Apply Litestar's request_max_body_size default when the AppConfig leaves it unset diff --git a/planning/releases/1.4.0.md b/planning/releases/1.4.0.md index d20c615..74bd2f0 100644 --- a/planning/releases/1.4.0.md +++ b/planning/releases/1.4.0.md @@ -81,6 +81,23 @@ Supplying `litestar_logging_middleware_config` while `litestar_logging_middleware_enabled` is `False` is a no-op that emits a warning — set the flag to actually turn logging on. +## Bug fixes + +- **Litestar apps can read request bodies again.** `Litestar.from_config()` — which + `LitestarBootstrapper` uses — passes every `AppConfig` field explicitly, so the 10 MB + `request_max_body_size` default that `Litestar(...)` applies never reached the built + app. Every handler taking a request body returned + `500: 'request_max_body_size' set to 'Empty' on all layers` unless the caller set the + field themselves. The bootstrapper now fills it when the config leaves it unset; a + caller's own value, including an explicit `None` for no limit, is untouched. Reported + upstream as [litestar#4296](https://github.com/litestar-org/litestar/issues/4296). +- **The `litestar` extra's floor moved from `>=2.9` to `>=2.15`.** `AppConfig.request_max_body_size`, + which the fix above now reads, was only added in litestar 2.13; and + `litestar.middleware.ASGIMiddleware`, which the OpenTelemetry middleware already subclassed + before this release, was only added in litestar 2.15. `>=2.9` was never actually supported for + the OTel path — this just makes the declared floor honest. + ## References - `planning/changes/2026-08-10.01-litestar-middleware-logging.md` +- `planning/changes/2026-08-10.03-litestar-request-max-body-size.md` diff --git a/pyproject.toml b/pyproject.toml index 5ebbbf1..7dc7514 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,7 +85,10 @@ fastapi-all = [ "lite-bootstrap[fastapi-sentry,fastapi-otl,fastapi-logging,fastapi-metrics,pyroscope]", ] litestar = [ - "litestar>=2.9", + # >=2.13 for AppConfig.request_max_body_size (LitestarBootstrapper._apply_config reads it); + # >=2.15 for litestar.middleware.ASGIMiddleware (LitestarOpenTelemetryInstrumentationMiddleware + # subclasses it) -- the higher floor wins. + "litestar>=2.15", ] litestar-sentry = [ "lite-bootstrap[litestar,sentry]", diff --git a/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index 53a2a8b..5c3850e 100644 --- a/tests/test_litestar_bootstrap.py +++ b/tests/test_litestar_bootstrap.py @@ -1,6 +1,7 @@ import contextlib import dataclasses import gc +import inspect import json import logging import sys @@ -24,6 +25,7 @@ from lite_bootstrap import LitestarBootstrapper, LitestarConfig, import_checker from lite_bootstrap.bootstrappers.litestar_bootstrapper import ( + _LITESTAR_DEFAULT_REQUEST_MAX_BODY_SIZE, LitestarLoggingInstrument, LitestarOpenTelemetryInstrumentationMiddleware, build_litestar_route_details_from_scope, @@ -322,7 +324,7 @@ def _access_log_records(log_lines: list[str]) -> list[dict[str, typing.Any]]: def _post_password(config: LitestarConfig) -> list[str]: """Bootstrap, POST credentials, and return the log lines Litestar emitted for that request.""" - @litestar.post("/login", request_max_body_size=1000) + @litestar.post("/login") async def _login_handler(data: dict[str, str]) -> dict[str, str]: return data @@ -457,3 +459,54 @@ def test_litestar_access_logging_excluded_paths_none_when_all_degenerate( assert instrument._build_logging_middleware_excluded_paths() == [] # noqa: SLF001 assert instrument._build_logging_middleware_config().exclude is None # noqa: SLF001 + + +def test_litestar_bootstrap_fills_unset_request_max_body_size(litestar_config: LitestarConfig) -> None: + @litestar.post("/echo") + async def echo_handler(data: dict[str, str]) -> dict[str, str]: + return data + + config = dataclasses.replace(litestar_config, application_config=AppConfig(route_handlers=[echo_handler])) + application = LitestarBootstrapper(bootstrap_config=config).bootstrap() + + with TestClient(app=application) as client: + response = client.post("/echo", json={"key": "value"}) + + assert response.status_code == status_codes.HTTP_201_CREATED + assert response.json() == {"key": "value"} + assert application.request_max_body_size == _LITESTAR_DEFAULT_REQUEST_MAX_BODY_SIZE + + +def test_litestar_bootstrap_keeps_explicit_request_max_body_size(litestar_config: LitestarConfig) -> None: + explicit_max_body_size = 42 + + @litestar.post("/echo") + async def echo_handler(data: dict[str, str]) -> dict[str, str]: + return data # pragma: no cover -- body exceeds the limit before this runs + + config = dataclasses.replace( + litestar_config, + application_config=AppConfig(route_handlers=[echo_handler], request_max_body_size=explicit_max_body_size), + ) + application = LitestarBootstrapper(bootstrap_config=config).bootstrap() + + with TestClient(app=application) as client: + response = client.post("/echo", json={"key": "value" * explicit_max_body_size}) + + assert response.status_code == status_codes.HTTP_413_REQUEST_ENTITY_TOO_LARGE + assert application.request_max_body_size == explicit_max_body_size + + +def test_litestar_bootstrap_keeps_explicit_unlimited_request_max_body_size(litestar_config: LitestarConfig) -> None: + config = dataclasses.replace(litestar_config, application_config=AppConfig(request_max_body_size=None)) + application = LitestarBootstrapper(bootstrap_config=config).bootstrap() + + with TestClient(app=application): + assert application.request_max_body_size is None + + +def test_litestar_default_request_max_body_size_matches_litestar() -> None: + """Guard: our constant must stay equal to the default Litestar's own __init__ applies.""" + litestar_default = inspect.signature(litestar.Litestar.__init__).parameters["request_max_body_size"].default + + assert litestar_default == _LITESTAR_DEFAULT_REQUEST_MAX_BODY_SIZE