From 6fa523eb4d5cd1d6a04561557ec721212ff24c4c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 10 Aug 2026 19:57:21 +0300 Subject: [PATCH 1/4] fix(litestar): apply Litestar's request_max_body_size default Litestar.from_config() passes every AppConfig field explicitly, so the 10 MB default Litestar.__init__ applies never reached a bootstrapped app and every body-reading handler returned 500. Fill the field in _apply_config when it is Empty, leaving a caller's own value (including an explicit None) alone. --- .../bootstrappers/litestar_bootstrapper.py | 9 ++++ tests/test_litestar_bootstrap.py | 44 +++++++++++++++++++ 2 files changed, 53 insertions(+) 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/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index 53a2a8b..60af728 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, @@ -457,3 +459,45 @@ 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 + config = dataclasses.replace( + litestar_config, application_config=AppConfig(request_max_body_size=explicit_max_body_size) + ) + application = LitestarBootstrapper(bootstrap_config=config).bootstrap() + + with TestClient(app=application): + 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 From 9d848beba3d0a271d4acaca7dc885940bdaecac0 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 10 Aug 2026 20:01:58 +0300 Subject: [PATCH 2/4] docs(litestar): promote the body-size fix and drop its workaround The access-logging tests no longer need request_max_body_size on their handler now that the bootstrapper fills the default. --- architecture/bootstrappers.md | 8 ++++++++ .../2026-08-10.03-litestar-request-max-body-size.md | 2 +- planning/releases/1.4.0.md | 12 ++++++++++++ tests/test_litestar_bootstrap.py | 2 +- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/architecture/bootstrappers.md b/architecture/bootstrappers.md index 6c3e436..186e4c3 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 is `_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/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..7e75342 100644 --- a/planning/releases/1.4.0.md +++ b/planning/releases/1.4.0.md @@ -81,6 +81,18 @@ 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). + ## 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/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index 60af728..ec1d8e0 100644 --- a/tests/test_litestar_bootstrap.py +++ b/tests/test_litestar_bootstrap.py @@ -324,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 From 43d984f59658048b3e13ff1aab822959b89dc442 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 10 Aug 2026 20:23:10 +0300 Subject: [PATCH 3/4] fix(litestar): raise the litestar extra floor to >=2.15 Two independently verified gaps in the declared >=2.9 floor: AppConfig.request_max_body_size, which LitestarBootstrapper._apply_config now reads, was added in litestar 2.13.0 (absent in 2.12.0) -- on an older litestar the attribute access raises AttributeError. Separately, litestar.middleware.ASGIMiddleware, which LitestarOpenTelemetryInstrumentationMiddleware already subclasses, was added in 2.15.0 (absent in 2.13 and 2.14) -- a pre-existing mismatch for anyone on the OTel path with litestar 2.9-2.14. Both confirmed by installing the relevant versions and inspecting their source. --- planning/releases/1.4.0.md | 5 +++++ pyproject.toml | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/planning/releases/1.4.0.md b/planning/releases/1.4.0.md index 7e75342..74bd2f0 100644 --- a/planning/releases/1.4.0.md +++ b/planning/releases/1.4.0.md @@ -91,6 +91,11 @@ warning — set the flag to actually turn logging on. 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 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]", From 77ac3b94150fcd2ccb7162deb24eb273eb81b54e Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 10 Aug 2026 20:24:15 +0300 Subject: [PATCH 4/4] docs(litestar): document request_max_body_size contract, tighten test Add a docs/integrations/litestar.md section (modeled on the Prometheus one) explaining that Litestar.from_config() skips the request_max_body_size default, that the bootstrapper fills it when AppConfig leaves it unset, and how to override it (including None for no limit), with a link to the upstream issue. Fix an imprecise sentence in architecture/bootstrappers.md: Litestar's attach thunk wraps _apply_config, it is not _apply_config itself. Strengthen test_litestar_bootstrap_keeps_explicit_request_max_body_size to prove the caller's limit is enforced, not just stored: it now posts an oversized body to a handler and asserts Litestar's actual 413 response, alongside the existing readback of the configured value. --- architecture/bootstrappers.md | 2 +- docs/integrations/litestar.md | 27 +++++++++++++++++++++++++++ tests/test_litestar_bootstrap.py | 15 ++++++++++++--- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/architecture/bootstrappers.md b/architecture/bootstrappers.md index 186e4c3..1adbf21 100644 --- a/architecture/bootstrappers.md +++ b/architecture/bootstrappers.md @@ -87,7 +87,7 @@ 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 is `_apply_config`, which also normalizes the `AppConfig` +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 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/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index ec1d8e0..5c3850e 100644 --- a/tests/test_litestar_bootstrap.py +++ b/tests/test_litestar_bootstrap.py @@ -479,13 +479,22 @@ async def echo_handler(data: dict[str, str]) -> dict[str, str]: 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(request_max_body_size=explicit_max_body_size) + 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): - assert application.request_max_body_size == explicit_max_body_size + 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: