Skip to content

Commit 2a1066d

Browse files
authored
fix: refuse to bootstrap a second bootstrapper on one application (#167)
_attach_teardown_once guarded the teardown hook and nothing else, so the losing bootstrapper still applied every instrument on bootstrap(). Litestar died with a duplicate-route error from inside the framework; FastAPI did not fail at all and silently registered a second copy of its health and metrics routes. bootstrap() now raises ConfigurationError naming the bootstrapper class. Construction still only warns, so the teardown seam's warn-and-skip invariant is unchanged. Covered for all four app-bearing frameworks, with a FreeBootstrapper test pinning that the guard does not fire where there is no attach target. The ownership marker is never cleared, including by teardown(), so a bootstrapped application stays owned for the life of the process; the remedy is documented in architecture/bootstrappers.md and the release notes. See planning/changes/2026-08-10.04-double-bootstrap-guard.md
1 parent cfb7650 commit 2a1066d

12 files changed

Lines changed: 160 additions & 8 deletions

‎CLAUDE.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,10 @@ Invariants (what must not break) — see the capability page for the full accoun
5454
instance is ignored. Construct exactly one `OpenTelemetryInstrument` per process. →
5555
`architecture/instruments.md`
5656
- **Teardown attaches once.** `_attach_teardown_once` guards against double-attach via
57-
the `_lite_bootstrap_teardown_attached` marker; `_lite_bootstrap_*`-prefixed
58-
attributes are the sanctioned way to tag user-supplied apps. →
59-
`architecture/bootstrappers.md`
57+
the `_lite_bootstrap_teardown_attached` marker; a second bootstrapper on an already-marked
58+
target warns at construction and its `bootstrap()` raises `ConfigurationError`.
59+
`_lite_bootstrap_*`-prefixed attributes are the sanctioned way to tag user-supplied
60+
apps. → `architecture/bootstrappers.md`
6061

6162
Capability index (all of `architecture/`):
6263

‎architecture/bootstrappers.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,25 @@ The guard is uniform: the same marker and warning apply to all four app-bearing
8787
frameworks. `attach` is typed `Callable[[], object]` because some hooks (FastStream's
8888
`on_shutdown`) return the callback.
8989

90+
The marker gates more than the teardown hook: it also gates instrument
91+
application. `_attach_teardown_once` records the skip on `self._attach_skipped`
92+
before returning, and `bootstrap()` checks that flag first — the losing
93+
bootstrapper raises `ConfigurationError` naming itself rather than re-applying
94+
every instrument against an application another bootstrapper already owns.
95+
Construction only warns; the losing bootstrapper is unusable from that point —
96+
only the failure itself is deferred to `bootstrap()`. `FreeBootstrapper` never
97+
calls `_attach_teardown_once` — it has no attach target — so it is unaffected;
98+
two `FreeBootstrapper`s bootstrap independently.
99+
100+
Nothing clears `_TEARDOWN_MARKER`, including `teardown()`. So once an
101+
application has been bootstrapped, it stays owned for the life of the process —
102+
a fresh bootstrapper constructed on it later still warns at construction and
103+
raises at `bootstrap()`. Clearing the marker on teardown is not the fix: for
104+
FastAPI, the lifespan wrapper the first bootstrapper installed via `_wrap_lifespan`
105+
stays merged into the app regardless of the marker, so a second bootstrapper would
106+
still be stacking its teardown behind one that's already there. The remedy is to
107+
construct a fresh application.
108+
90109
Litestar's `attach` thunk wraps `_apply_config`, which also normalizes the `AppConfig`
91110
it is handed before `Litestar.from_config()` builds the app: it sets `debug` from
92111
`service_debug`, and fills `request_max_body_size` with Litestar's own 10 MB default

‎lite_bootstrap/bootstrappers/base.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from lite_bootstrap.exceptions import (
77
BootstrapperNotReadyError,
8+
ConfigurationError,
89
InstrumentDependencyMissingWarning,
910
TeardownError,
1011
)
@@ -39,10 +40,11 @@ def _attach_teardown_once(self, target: object, attach: typing.Callable[[], obje
3940
if getattr(target, self._TEARDOWN_MARKER, False):
4041
warnings.warn(
4142
f"{type(self).__name__} already has a lite-bootstrap teardown hook attached to this "
42-
f"application or its configuration; skipping. This {type(self).__name__}'s teardown "
43-
f"will not run on shutdown — construct one {type(self).__name__} per application.",
43+
f"application or its configuration; skipping. This {type(self).__name__} cannot be used — "
44+
f"its bootstrap() will raise — so construct one {type(self).__name__} per application.",
4445
stacklevel=3,
4546
)
47+
self._attach_skipped = True
4648
return
4749
# Mark only after a successful attach: if attach() raises, the target stays untagged
4850
# so a retry can re-attach rather than silently warning-and-skipping forever.
@@ -69,6 +71,8 @@ def build_summary(self) -> str:
6971

7072
def __init__(self, bootstrap_config: BaseConfig) -> None:
7173
self.is_bootstrapped = False
74+
# Set when another bootstrapper already owns this application; bootstrap() then refuses.
75+
self._attach_skipped = False
7276
if not self.is_ready():
7377
msg = f"{type(self).__name__} is not ready: {self.not_ready_message}"
7478
raise BootstrapperNotReadyError(msg)
@@ -112,6 +116,13 @@ def _prepare_application(self) -> ApplicationT: ...
112116
def is_ready(self) -> bool: ...
113117

114118
def bootstrap(self) -> ApplicationT:
119+
if self._attach_skipped:
120+
msg = (
121+
f"{type(self).__name__} shares its application with another lite-bootstrap "
122+
f"bootstrapper, which already owns it. Construct one "
123+
f"{type(self).__name__} per application."
124+
)
125+
raise ConfigurationError(msg)
115126
if self.is_bootstrapped:
116127
return self._prepare_application()
117128
self.is_bootstrapped = True

‎lite_bootstrap/exceptions.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ class BootstrapperNotReadyError(LiteBootstrapError):
77

88

99
class ConfigurationError(LiteBootstrapError):
10-
"""Raised when a config is invalid or a required optional dependency is missing."""
10+
"""Raised when a config is invalid or a required optional dependency is missing.
11+
12+
Also raised when a bootstrapper is constructed on an application another bootstrapper
13+
already owns.
14+
"""
1115

1216

1317
class TeardownError(LiteBootstrapError):

‎planning/changes/2026-08-10.04-double-bootstrap-guard.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
summary: Make a second bootstrapper on an already-bootstrapped application fail loudly — `bootstrap()` raises `ConfigurationError` instead of dying inside Litestar with a duplicate-route error or silently double-registering FastAPI's routes.
2+
summary: A second bootstrapper on the same application now fails loudly — `bootstrap()` raises `ConfigurationError` on the bootstrapper whose teardown attach was skipped, instead of dying inside Litestar with a duplicate-route error or silently double-registering FastAPI's routes. The construction-time warning is unchanged in kind, reworded to say the second bootstrapper's `bootstrap()` will raise. `FreeBootstrapper` has no attach target and is unaffected.
33
---
44

55
# Design: Fail fast when a second bootstrapper targets the same application

‎planning/decisions/2026-06-24-teardown-marker-accepted-limits.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,13 @@ accepted; it is out of scope for this decision.
6565
- **Litestar:** sharing one `AppConfig` across multiple apps becomes a supported,
6666
documented pattern, or the attach is restructured to run at `bootstrap()` time
6767
(when the app exists). Then tag the built `Litestar` app instead of the config.
68+
69+
## Update (1.4.0)
70+
71+
[double-bootstrap-guard](../changes/2026-08-10.04-double-bootstrap-guard.md) changed
72+
the consequence both scenarios above describe. The FastMCP case is no longer "a
73+
second bootstrapper warns-and-skips instead of re-attaching" — its `bootstrap()`
74+
now raises `ConfigurationError`. The Litestar case is no longer "the second warns
75+
and skips, and its teardown never runs" — its `bootstrap()` raises before any
76+
instrument is applied, so there is nothing left half-wired. The marker and its two
77+
accepted limits are unchanged; only what happens once the marker is hit got louder.

‎planning/releases/1.4.0.md‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# lite-bootstrap 1.4.0 — Litestar access logging off by default
22

3-
**1.4.0 is a minor release with a behavior change for Litestar services.**
3+
**1.4.0 is a minor release with a behavior change for Litestar services.** A
4+
second bootstrapper sharing an application also now fails loudly instead of
5+
corrupting it — see [Bug fixes](#bug-fixes) below.
46

57
## Behavior change
68

@@ -101,8 +103,22 @@ warning — set the flag to actually turn logging on.
101103
`litestar.middleware.ASGIMiddleware`, which the OpenTelemetry middleware already subclassed
102104
before this release, was only added in litestar 2.15. `>=2.9` was never actually supported for
103105
the OTel path — this just makes the declared floor honest.
106+
- **A second bootstrapper sharing an application now fails loudly instead of corrupting it.**
107+
Constructing two bootstrappers (FastAPI, Litestar, FastStream, or FastMCP) against the same
108+
application already warned at construction time, but `bootstrap()` on the second one applied
109+
every instrument again anyway. Litestar died with an unrelated-looking
110+
`ImproperlyConfiguredException: Handler already registered for path '/health' and http method
111+
OPTIONS`; FastAPI did not fail at all — the app's route count silently grew (e.g. from 6 to 8
112+
for a default config), a shadowed duplicate of the health-check and metrics routes.
113+
`bootstrap()` on the losing bootstrapper now raises `ConfigurationError` naming itself. If your
114+
code relied on the FastAPI case appearing to "work", it will now raise. The ownership marker
115+
behind this is never cleared, including by `teardown()` — once an application has been
116+
bootstrapped, it stays owned for the life of the process; construct a fresh application rather
117+
than reusing one that was already bootstrapped.
104118

105119
## References
106120

107121
- `planning/changes/2026-08-10.01-litestar-middleware-logging.md`
122+
- `planning/changes/2026-08-10.02-log-stream-bind-at-bootstrap.md`
108123
- `planning/changes/2026-08-10.03-litestar-request-max-body-size.md`
124+
- `planning/changes/2026-08-10.04-double-bootstrap-guard.md`

‎tests/test_fastapi_bootstrap.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from lite_bootstrap import FastAPIBootstrapper, FastAPIConfig
1212
from lite_bootstrap.bootstrappers.fastapi_bootstrapper import _narrow_app
13+
from lite_bootstrap.exceptions import ConfigurationError
1314
from lite_bootstrap.types import UNSET
1415
from tests.conftest import CustomInstrumentor, SentryTestTransport, emulate_package_missing
1516

@@ -166,3 +167,24 @@ def test_fastapi_config_inherits_otel_insecure_warning() -> None:
166167
)
167168
matching = [w for w in caught if "unencrypted" in str(w.message)]
168169
assert matching, [str(w.message) for w in caught]
170+
171+
172+
def test_second_fastapi_bootstrapper_bootstrap_raises(fastapi_config: FastAPIConfig) -> None:
173+
application = fastapi.FastAPI()
174+
first = FastAPIBootstrapper(bootstrap_config=dataclasses.replace(fastapi_config, application=application))
175+
with warnings.catch_warnings():
176+
warnings.simplefilter("ignore")
177+
second = FastAPIBootstrapper(bootstrap_config=dataclasses.replace(fastapi_config, application=application))
178+
179+
try:
180+
first.bootstrap()
181+
routes_after_first = len(application.routes)
182+
183+
with pytest.raises(ConfigurationError, match="FastAPIBootstrapper"):
184+
second.bootstrap()
185+
186+
assert len(application.routes) == routes_after_first, (
187+
"a refused second bootstrap must not register duplicate routes"
188+
)
189+
finally:
190+
first.teardown()

‎tests/test_fastmcp_bootstrap.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from lite_bootstrap import BootstrapperNotReadyError, FastMcpBootstrapper, FastMcpConfig
1414
from lite_bootstrap.bootstrappers.fastmcp_bootstrapper import FastMcpLoggingMiddleware
15+
from lite_bootstrap.exceptions import ConfigurationError
1516
from tests.conftest import emulate_package_missing, emulate_package_missing_with_module_reload
1617

1718

@@ -302,3 +303,18 @@ def test_fastmcp_bootstrap_without_structlog() -> None:
302303
bootstrapper = FastMcpBootstrapper(bootstrap_config=FastMcpConfig())
303304
bootstrapper.bootstrap()
304305
bootstrapper.teardown()
306+
307+
308+
def test_second_fastmcp_bootstrapper_bootstrap_raises() -> None:
309+
application = FastMCP()
310+
first = FastMcpBootstrapper(bootstrap_config=FastMcpConfig(application=application, service_name="a"))
311+
with warnings.catch_warnings():
312+
warnings.simplefilter("ignore")
313+
second = FastMcpBootstrapper(bootstrap_config=FastMcpConfig(application=application, service_name="b"))
314+
315+
try:
316+
first.bootstrap()
317+
with pytest.raises(ConfigurationError, match="FastMcpBootstrapper"):
318+
second.bootstrap()
319+
finally:
320+
first.teardown()

‎tests/test_faststream_bootstrap.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
FastStreamLoggingInstrument,
2323
FastStreamOpenTelemetryInstrument,
2424
)
25+
from lite_bootstrap.exceptions import ConfigurationError
2526
from tests.conftest import (
2627
CustomInstrumentor,
2728
SentryTestTransport,
@@ -299,3 +300,18 @@ def params_storage(self, _value: object) -> None:
299300
instrument.teardown()
300301
# If super().teardown() ran, structlog defaults were reset — no exception below.
301302
structlog.get_logger("verify-reset")
303+
304+
305+
def test_second_faststream_bootstrapper_bootstrap_raises(broker: RedisBroker) -> None:
306+
config_a = build_faststream_config(broker=broker)
307+
first = FastStreamBootstrapper(bootstrap_config=config_a)
308+
with warnings.catch_warnings():
309+
warnings.simplefilter("ignore")
310+
second = FastStreamBootstrapper(bootstrap_config=dataclasses.replace(config_a))
311+
312+
try:
313+
first.bootstrap()
314+
with pytest.raises(ConfigurationError, match="FastStreamBootstrapper"):
315+
second.bootstrap()
316+
finally:
317+
first.teardown()

0 commit comments

Comments
 (0)