|
| 1 | +--- |
| 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. |
| 3 | +--- |
| 4 | + |
| 5 | +# Design: Apply Litestar's request_max_body_size default when the AppConfig leaves it unset |
| 6 | + |
| 7 | +## Summary |
| 8 | + |
| 9 | +`LitestarBootstrapper` builds its application with `Litestar.from_config()`, |
| 10 | +which — unlike `Litestar(...)` — does not apply the 10 MB |
| 11 | +`request_max_body_size` default. An `AppConfig` that leaves the field at |
| 12 | +`Empty` therefore yields an application where every handler that reads a |
| 13 | +request body returns 500. Fill the field in the bootstrapper when, and only |
| 14 | +when, it is `Empty`. |
| 15 | + |
| 16 | +## Motivation |
| 17 | + |
| 18 | +Reproduced on litestar 2.24.0: |
| 19 | + |
| 20 | +```python |
| 21 | +app = litestar.Litestar(route_handlers=[echo]) # request_max_body_size == 10_000_000 |
| 22 | +app = litestar.Litestar.from_config(AppConfig(...)) # request_max_body_size is Empty |
| 23 | +``` |
| 24 | + |
| 25 | +With the second form, a `POST` to a handler taking `data: dict` returns 500: |
| 26 | + |
| 27 | +``` |
| 28 | +ImproperlyConfiguredException: 500: 'request_max_body_size' set to 'Empty' on all layers. |
| 29 | +To omit a limit, set 'request_max_body_size=None' |
| 30 | +``` |
| 31 | + |
| 32 | +`LitestarConfig.application_config` defaults to a bare `AppConfig()`, and a |
| 33 | +caller who supplies their own `AppConfig` hits the same default, so **the |
| 34 | +failure is the norm rather than the edge case**: any lite-bootstrap Litestar |
| 35 | +service whose handlers accept a body 500s unless the caller happens to know to |
| 36 | +set `request_max_body_size` themselves. It is not per-route recoverable either |
| 37 | +— the exception is raised while resolving the layered value, so the only fixes |
| 38 | +are on the handler, a router, or the app. |
| 39 | + |
| 40 | +Found while fixing the access-log body leak |
| 41 | +(`planning/changes/2026-08-10.01-litestar-middleware-logging.md`), whose tests |
| 42 | +work around it with `request_max_body_size=1000` on their handlers. That |
| 43 | +workaround is what should disappear. |
| 44 | + |
| 45 | +## Design |
| 46 | + |
| 47 | +`LitestarBootstrapper._apply_config` already owns exactly this job — it is the |
| 48 | +one place that mutates the caller's `AppConfig` before |
| 49 | +`Litestar.from_config()` runs, setting `debug` and appending the teardown hook. |
| 50 | +Add the fill there: |
| 51 | + |
| 52 | +```python |
| 53 | +# litestar_bootstrapper.py, module level |
| 54 | +# Litestar.from_config() skips the default that Litestar.__init__ applies, leaving the |
| 55 | +# field Empty and 500-ing every body-reading handler. Pinned by a guard test. |
| 56 | +_LITESTAR_DEFAULT_REQUEST_MAX_BODY_SIZE: typing.Final = 10_000_000 |
| 57 | +``` |
| 58 | + |
| 59 | +```python |
| 60 | + def _apply_config(self, application_config: "AppConfig") -> None: |
| 61 | + application_config.debug = self.bootstrap_config.service_debug |
| 62 | + if application_config.request_max_body_size is Empty: |
| 63 | + application_config.request_max_body_size = _LITESTAR_DEFAULT_REQUEST_MAX_BODY_SIZE |
| 64 | + application_config.on_shutdown.append(self.teardown) |
| 65 | +``` |
| 66 | + |
| 67 | +`Empty` is an enum member (`litestar.types.Empty`, `_EmptyEnum.EMPTY`), not a |
| 68 | +class, so the check is an identity comparison — `isinstance` would raise. |
| 69 | +`Empty` joins the existing `if import_checker.is_litestar_installed:` import |
| 70 | +block. |
| 71 | + |
| 72 | +The guard is the `is Empty` test: a caller's own value, including an explicit |
| 73 | +`None` (Litestar's "no limit"), is left alone. Only the unset case is filled. |
| 74 | + |
| 75 | +**Pinning the constant.** Litestar exposes no public constant for the default; |
| 76 | +the value lives only in `Litestar.__init__`'s signature, so hardcoding it can |
| 77 | +drift silently on a Litestar bump. A guard test reads the signature default and |
| 78 | +asserts it equals our constant, turning a drift into a CI failure rather than a |
| 79 | +behavior change nobody notices. Runtime introspection was rejected: it makes |
| 80 | +every bootstrap depend on a parameter name Litestar does not publish as API, |
| 81 | +and it fails opaquely if that name changes. |
| 82 | + |
| 83 | +**Upstream.** `Litestar.from_config()` diverging from `Litestar(...)` on a |
| 84 | +constructor default is already reported as |
| 85 | +[litestar-org/litestar#4296](https://github.com/litestar-org/litestar/issues/4296), |
| 86 | +which lists five such mismatches; our reproduction and the reason this one is a |
| 87 | +hard failure rather than a cosmetic difference are in |
| 88 | +[a comment there](https://github.com/litestar-org/litestar/issues/4296#issuecomment-5243196116). |
| 89 | +`from_config` passes every `AppConfig` field explicitly |
| 90 | +(`cls(**dict(extract_dataclass_items(config)))`), so an `__init__` default can |
| 91 | +never apply — and on 2.24.0 `request_max_body_size` is the only field where |
| 92 | +`AppConfig()` is `Empty` while `__init__` has a real default. If upstream fixes |
| 93 | +it, the `is Empty` branch simply stops firing and the guard test keeps the |
| 94 | +constant honest until the fill can be dropped. |
| 95 | + |
| 96 | +## Non-goals |
| 97 | + |
| 98 | +- Exposing `request_max_body_size` as a `LitestarConfig` field. Callers who |
| 99 | + want a non-default limit set it on their own `AppConfig`, which is where |
| 100 | + every other Litestar app-level knob already lives. |
| 101 | +- Auditing the other `AppConfig` fields where `from_config()` may diverge from |
| 102 | + `Litestar.__init__`. If more turn up, they get their own change. |
| 103 | + |
| 104 | +## Testing |
| 105 | + |
| 106 | +`just test -k "request_max_body_size"`, in `tests/test_litestar_bootstrap.py`: |
| 107 | + |
| 108 | +- a bootstrapped app with a body-reading handler and no explicit |
| 109 | + `request_max_body_size`: `POST` succeeds (today: 500). |
| 110 | +- a caller-supplied value survives: `AppConfig(request_max_body_size=42)` |
| 111 | + bootstraps to `42`. |
| 112 | +- an explicit `None` (Litestar's no-limit form) survives as `None`. |
| 113 | +- guard: `inspect.signature(litestar.Litestar.__init__).parameters["request_max_body_size"].default` |
| 114 | + equals `_LITESTAR_DEFAULT_REQUEST_MAX_BODY_SIZE`. |
| 115 | + |
| 116 | +Once green, drop the `request_max_body_size=1000` workaround from the |
| 117 | +access-logging tests added in `2026-08-10.01`, so the suite stops carrying a |
| 118 | +note about a defect that no longer exists. |
| 119 | + |
| 120 | +Then `just lint-ci` and the full `just test`. |
| 121 | + |
| 122 | +## Risk |
| 123 | + |
| 124 | +**The constant drifts from Litestar's default.** Low likelihood, low impact |
| 125 | +(the number is a size limit), and the guard test converts it into a failed CI |
| 126 | +run on the bump that changes it. |
| 127 | + |
| 128 | +**A caller relying on the 500.** Implausible — it is an |
| 129 | +`ImproperlyConfiguredException`, not a documented limit. |
| 130 | + |
| 131 | +**Promotion:** `architecture/bootstrappers.md` records that `_apply_config` now |
| 132 | +also fills Litestar's unset body-size default, alongside `debug` and the |
| 133 | +teardown hook. |
0 commit comments