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
74 changes: 74 additions & 0 deletions planning/changes/2026-08-10.02-log-stream-bind-at-bootstrap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
summary: Resolve `_MemoryLoggerFactoryConfig.log_stream` at bootstrap instead of at import, so structlog output follows a stdout the process rebinds after importing `lite_bootstrap` (the root-logger handler already does).
---

# Change: Bind the memory logger's stream at bootstrap, not at import

**Lane:** lightweight — ≲30 LOC net, ≤2 files, no new file, no public-API
change, a single straightforward test.

## Goal

`_MemoryLoggerFactoryConfig.log_stream` (`lite_bootstrap/instruments/logging_factory.py`)
defaults to a bare `sys.stdout`, evaluated once when the module is imported.
Every `MemoryLoggerFactory` handler therefore writes to whatever `sys.stdout`
was at import time, even when the process rebinds `sys.stdout` before
bootstrapping. Resolve it at bootstrap instead.

## Approach

```python
log_stream: typing.Any = dataclasses.field(default_factory=lambda: sys.stdout)
```

The config is constructed in `LoggingInstrument.memory_logger_factory`, so a
`default_factory` moves the lookup to bootstrap time — the same moment
`_configure_foreign_loggers` already binds its root-logger
`logging.StreamHandler(sys.stdout)`. Today the two disagree: the root handler
follows a rebound stdout and the structlog path does not.

Observed with a plain `FreeBootstrapper` (all bootstrappers share
`LoggingInstrument`, so this is not Litestar-specific):

```python
with contextlib.redirect_stdout(buffer):
FreeBootstrapper(bootstrap_config=FreeConfig(service_name="svc", logging_buffer_capacity=0)).bootstrap()
structlog.get_logger("demo").info("hello after redirect")
# buffer is empty; the line went to the real stdout instead
```

Two consequences worth naming. In production, anything that wraps or replaces
`sys.stdout` after import — `contextlib.redirect_stdout`, a supervisor that
re-points the stream, a test harness — is silently bypassed by structlog output
while stdlib output follows along. In this repo's own test suite it is why
neither `capsys` nor `capfd` can observe structlog lines (pytest installs its
capture before collection imports the module), which forced
`tests/test_litestar_bootstrap.py` to record through a handler attached to the
`litestar` logger. That workaround stays either way; it is independent of the
capture mechanism, which is the point of it.

Behavior is unchanged for the ordinary case, where nothing rebinds `sys.stdout`
between import and bootstrap.

## Files

- `lite_bootstrap/instruments/logging_factory.py` — `log_stream` gains a `default_factory`.
- `tests/instruments/test_logging_instrument.py` — test added.

## Verification

- [ ] Failing test first: bootstrap a `FreeBootstrapper` inside
`contextlib.redirect_stdout(io.StringIO())`, log one line, assert it lands
in the buffer. Command: `just test -k "log_stream"`. Expected failure: the
buffer is empty because the line went to the import-time stdout.
- [ ] Apply the change.
- [ ] Test passes — `just test -k "log_stream"`.
- [ ] `just test` — full suite green, coverage still 100%.
- [ ] `just lint` — clean.

## Notes

Found while fixing the Litestar access-log body leak
(`planning/changes/2026-08-10.01-litestar-middleware-logging.md`), which is
where the test-capture consequence is documented. Deliberately left out of that
change to keep a security fix unencumbered.
133 changes: 133 additions & 0 deletions planning/changes/2026-08-10.03-litestar-request-max-body-size.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
---
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.
---

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

## Summary

`LitestarBootstrapper` builds its application with `Litestar.from_config()`,
which — unlike `Litestar(...)` — does not apply the 10 MB
`request_max_body_size` default. An `AppConfig` that leaves the field at
`Empty` therefore yields an application where every handler that reads a
request body returns 500. Fill the field in the bootstrapper when, and only
when, it is `Empty`.

## Motivation

Reproduced on litestar 2.24.0:

```python
app = litestar.Litestar(route_handlers=[echo]) # request_max_body_size == 10_000_000
app = litestar.Litestar.from_config(AppConfig(...)) # request_max_body_size is Empty
```

With the second form, a `POST` to a handler taking `data: dict` returns 500:

```
ImproperlyConfiguredException: 500: 'request_max_body_size' set to 'Empty' on all layers.
To omit a limit, set 'request_max_body_size=None'
```

`LitestarConfig.application_config` defaults to a bare `AppConfig()`, and a
caller who supplies their own `AppConfig` hits the same default, so **the
failure is the norm rather than the edge case**: any lite-bootstrap Litestar
service whose handlers accept a body 500s unless the caller happens to know to
set `request_max_body_size` themselves. It is not per-route recoverable either
— the exception is raised while resolving the layered value, so the only fixes
are on the handler, a router, or the app.

Found while fixing the access-log body leak
(`planning/changes/2026-08-10.01-litestar-middleware-logging.md`), whose tests
work around it with `request_max_body_size=1000` on their handlers. That
workaround is what should disappear.

## Design

`LitestarBootstrapper._apply_config` already owns exactly this job — it is the
one place that mutates the caller's `AppConfig` before
`Litestar.from_config()` runs, setting `debug` and appending the teardown hook.
Add the fill there:

```python
# litestar_bootstrapper.py, module level
# Litestar.from_config() skips the default that Litestar.__init__ applies, leaving the
# field Empty and 500-ing every body-reading handler. Pinned by a guard test.
_LITESTAR_DEFAULT_REQUEST_MAX_BODY_SIZE: typing.Final = 10_000_000
```

```python
def _apply_config(self, application_config: "AppConfig") -> None:
application_config.debug = self.bootstrap_config.service_debug
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)
```

`Empty` is an enum member (`litestar.types.Empty`, `_EmptyEnum.EMPTY`), not a
class, so the check is an identity comparison — `isinstance` would raise.
`Empty` joins the existing `if import_checker.is_litestar_installed:` import
block.

The guard is the `is Empty` test: a caller's own value, including an explicit
`None` (Litestar's "no limit"), is left alone. Only the unset case is filled.

**Pinning the constant.** Litestar exposes no public constant for the default;
the value lives only in `Litestar.__init__`'s signature, so hardcoding it can
drift silently on a Litestar bump. A guard test reads the signature default and
asserts it equals our constant, turning a drift into a CI failure rather than a
behavior change nobody notices. Runtime introspection was rejected: it makes
every bootstrap depend on a parameter name Litestar does not publish as API,
and it fails opaquely if that name changes.

**Upstream.** `Litestar.from_config()` diverging from `Litestar(...)` on a
constructor default is already reported as
[litestar-org/litestar#4296](https://github.com/litestar-org/litestar/issues/4296),
which lists five such mismatches; our reproduction and the reason this one is a
hard failure rather than a cosmetic difference are in
[a comment there](https://github.com/litestar-org/litestar/issues/4296#issuecomment-5243196116).
`from_config` passes every `AppConfig` field explicitly
(`cls(**dict(extract_dataclass_items(config)))`), so an `__init__` default can
never apply — and on 2.24.0 `request_max_body_size` is the only field where
`AppConfig()` is `Empty` while `__init__` has a real default. If upstream fixes
it, the `is Empty` branch simply stops firing and the guard test keeps the
constant honest until the fill can be dropped.

## Non-goals

- Exposing `request_max_body_size` as a `LitestarConfig` field. Callers who
want a non-default limit set it on their own `AppConfig`, which is where
every other Litestar app-level knob already lives.
- Auditing the other `AppConfig` fields where `from_config()` may diverge from
`Litestar.__init__`. If more turn up, they get their own change.

## Testing

`just test -k "request_max_body_size"`, in `tests/test_litestar_bootstrap.py`:

- a bootstrapped app with a body-reading handler and no explicit
`request_max_body_size`: `POST` succeeds (today: 500).
- a caller-supplied value survives: `AppConfig(request_max_body_size=42)`
bootstraps to `42`.
- an explicit `None` (Litestar's no-limit form) survives as `None`.
- guard: `inspect.signature(litestar.Litestar.__init__).parameters["request_max_body_size"].default`
equals `_LITESTAR_DEFAULT_REQUEST_MAX_BODY_SIZE`.

Once green, drop the `request_max_body_size=1000` workaround from the
access-logging tests added in `2026-08-10.01`, so the suite stops carrying a
note about a defect that no longer exists.

Then `just lint-ci` and the full `just test`.

## Risk

**The constant drifts from Litestar's default.** Low likelihood, low impact
(the number is a size limit), and the guard test converts it into a failed CI
run on the bump that changes it.

**A caller relying on the 500.** Implausible — it is an
`ImproperlyConfiguredException`, not a documented limit.

**Promotion:** `architecture/bootstrappers.md` records that `_apply_config` now
also fills Litestar's unset body-size default, alongside `debug` and the
teardown hook.