Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions architecture/bootstrappers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions docs/integrations/litestar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 9 additions & 0 deletions lite_bootstrap/bootstrappers/litestar_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
17 changes: 17 additions & 0 deletions planning/releases/1.4.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
Expand Down
55 changes: 54 additions & 1 deletion tests/test_litestar_bootstrap.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import contextlib
import dataclasses
import gc
import inspect
import json
import logging
import sys
Expand All @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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