From 91066e629e4698bdd85efab547a02d2e24d7979a Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 28 Oct 2026 10:32:51 +0300 Subject: [PATCH 1/2] fix: raise ConfigurationError from bootstrap() after teardown() teardown() reset is_bootstrapped, so a later bootstrap() ran every instrument again against the application it had already changed. Litestar raised on the duplicate /health route; FastAPI, FastStream and FastMCP silently registered health and metrics routes and Prometheus middleware twice. A torn-down bootstrapper now refuses to bootstrap again. Fixes #326. --- CONTEXT.md | 5 +++-- lite_bootstrap/bootstrappers/base.py | 9 +++++++++ tests/test_fastapi_bootstrap.py | 8 ++++++++ tests/test_fastmcp_bootstrap.py | 8 ++++++++ tests/test_faststream_bootstrap.py | 8 ++++++++ tests/test_free_bootstrap.py | 9 +++++++++ tests/test_litestar_bootstrap.py | 11 +++++------ 7 files changed, 50 insertions(+), 8 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index ca60147..b71963a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -14,7 +14,8 @@ project uses it. The user-facing entry point, one class per framework. It owns exactly one application for the life of the process; it is not a builder you can run twice. A second bootstrapper on the same application (or, for Litestar, the same `AppConfig`) warns at construction and raises `ConfigurationError` from -`bootstrap()`. +`bootstrap()`. Once torn down, a bootstrapper cannot bootstrap again: `bootstrap()` after +`teardown()` raises `ConfigurationError`. **Instrument**: One observability or middleware concern: logging, tracing, metrics, profiling, CORS, Swagger, or @@ -55,7 +56,7 @@ possible probe. **Free**: Without a web framework: `FreeBootstrapper`, `FreeConfig`, the `free-all` extra. It has no -application, so nothing to attach teardown to and no double-bootstrap guard. +application, so nothing to attach teardown to and no guard against a second bootstrapper. _Avoid_: bare "free" for PEP 703 free-threaded CPython, which this project also supports. Write "free-threaded" in full. diff --git a/lite_bootstrap/bootstrappers/base.py b/lite_bootstrap/bootstrappers/base.py index 1bb8dea..b08bcc2 100644 --- a/lite_bootstrap/bootstrappers/base.py +++ b/lite_bootstrap/bootstrappers/base.py @@ -34,6 +34,7 @@ class BaseBootstrapper(abc.ABC, typing.Generic[ApplicationT]): def __init__(self, bootstrap_config: BaseConfig) -> None: self.is_bootstrapped = False + self._torn_down = False # Set when another bootstrapper already owns this application; bootstrap() then refuses. self._attach_skipped = False if not self.is_ready(): @@ -131,6 +132,13 @@ def bootstrap(self) -> ApplicationT: f"{type(self).__name__} per application." ) raise ConfigurationError(msg) + if self._torn_down: + msg = ( + f"{type(self).__name__} was torn down and cannot bootstrap again: its instruments " + f"already changed the application. To bootstrap again, construct a new " + f"{type(self).__name__} on a new application." + ) + raise ConfigurationError(msg) if self.is_bootstrapped: return self._application self.is_bootstrapped = True @@ -143,6 +151,7 @@ def teardown(self) -> None: if not self.is_bootstrapped: return self.is_bootstrapped = False + self._torn_down = True with collect_teardown_errors(logger) as teardown_errors: for one_instrument in reversed(self.instruments): with teardown_errors.capture(type(one_instrument).__name__): diff --git a/tests/test_fastapi_bootstrap.py b/tests/test_fastapi_bootstrap.py index 7760249..ed5e32e 100644 --- a/tests/test_fastapi_bootstrap.py +++ b/tests/test_fastapi_bootstrap.py @@ -214,6 +214,14 @@ def test_fastapi_config_inherits_otel_insecure_warning() -> None: assert matching, [str(w.message) for w in caught] +def test_fastapi_bootstrap_after_teardown_raises() -> None: + bootstrapper = FastAPIBootstrapper(bootstrap_config=FastAPIConfig()) + bootstrapper.bootstrap() + bootstrapper.teardown() + with pytest.raises(ConfigurationError, match="construct a new FastAPIBootstrapper"): + bootstrapper.bootstrap() + + def test_second_fastapi_bootstrapper_bootstrap_raises(fastapi_config: FastAPIConfig) -> None: application = fastapi.FastAPI() first = FastAPIBootstrapper(bootstrap_config=dataclasses.replace(fastapi_config, application=application)) diff --git a/tests/test_fastmcp_bootstrap.py b/tests/test_fastmcp_bootstrap.py index 783eace..a348c4a 100644 --- a/tests/test_fastmcp_bootstrap.py +++ b/tests/test_fastmcp_bootstrap.py @@ -346,6 +346,14 @@ def test_fastmcp_bootstrap_without_structlog() -> None: bootstrapper.teardown() +def test_fastmcp_bootstrap_after_teardown_raises() -> None: + bootstrapper = FastMcpBootstrapper(bootstrap_config=FastMcpConfig()) + bootstrapper.bootstrap() + bootstrapper.teardown() + with pytest.raises(ConfigurationError, match="construct a new FastMcpBootstrapper"): + bootstrapper.bootstrap() + + def test_second_fastmcp_bootstrapper_bootstrap_raises() -> None: application = FastMCP() first = FastMcpBootstrapper(bootstrap_config=FastMcpConfig(application=application, service_name="a")) diff --git a/tests/test_faststream_bootstrap.py b/tests/test_faststream_bootstrap.py index f4e103f..b1b02ef 100644 --- a/tests/test_faststream_bootstrap.py +++ b/tests/test_faststream_bootstrap.py @@ -399,6 +399,14 @@ def _raise_on_write(_self: object, _value: object) -> None: structlog.get_logger("verify-reset") +def test_faststream_bootstrap_after_teardown_raises() -> None: + bootstrapper = FastStreamBootstrapper(bootstrap_config=FastStreamConfig()) + bootstrapper.bootstrap() + bootstrapper.teardown() + with pytest.raises(ConfigurationError, match="construct a new FastStreamBootstrapper"): + bootstrapper.bootstrap() + + def test_second_faststream_bootstrapper_bootstrap_raises(broker: RedisBroker) -> None: config_a = build_faststream_config(broker=broker) first = FastStreamBootstrapper(bootstrap_config=config_a) diff --git a/tests/test_free_bootstrap.py b/tests/test_free_bootstrap.py index 61f457b..237e51e 100644 --- a/tests/test_free_bootstrap.py +++ b/tests/test_free_bootstrap.py @@ -8,6 +8,7 @@ from opentelemetry.sdk.trace import sampling from lite_bootstrap import ( + ConfigurationError, FreeBootstrapper, FreeConfig, InstrumentDependencyMissingWarning, @@ -35,6 +36,14 @@ def free_bootstrapper_config() -> FreeConfig: ) +def test_free_bootstrap_after_teardown_raises() -> None: + bootstrapper = FreeBootstrapper(bootstrap_config=FreeConfig()) + bootstrapper.bootstrap() + bootstrapper.teardown() + with pytest.raises(ConfigurationError, match="construct a new FreeBootstrapper"): + bootstrapper.bootstrap() + + def test_free_bootstrap(free_bootstrapper_config: FreeConfig) -> None: bootstrapper = FreeBootstrapper(bootstrap_config=free_bootstrapper_config) bootstrapper.bootstrap() diff --git a/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index f135fe0..9d982cf 100644 --- a/tests/test_litestar_bootstrap.py +++ b/tests/test_litestar_bootstrap.py @@ -124,13 +124,12 @@ def test_litestar_bootstrap_twice_returns_the_same_application() -> None: assert bootstrapper.bootstrap() is bootstrapper.bootstrap() -def test_litestar_bootstrap_after_teardown_builds_a_new_application() -> None: - bootstrapper = LitestarBootstrapper( - LitestarConfig(logging_enabled=False, health_checks_enabled=False, prometheus_metrics_path="") - ) - first = bootstrapper.bootstrap() +def test_litestar_bootstrap_after_teardown_raises() -> None: + bootstrapper = LitestarBootstrapper(bootstrap_config=LitestarConfig()) + bootstrapper.bootstrap() bootstrapper.teardown() - assert bootstrapper.bootstrap() is not first + with pytest.raises(ConfigurationError, match="construct a new LitestarBootstrapper"): + bootstrapper.bootstrap() def test_litestar_bootstrapper_not_ready() -> None: From 10ad1e5cc690533b06ed5335cfcba75bcbfadca3 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 28 Oct 2026 10:33:08 +0300 Subject: [PATCH 2/2] fix: word the after-teardown error for Free too --- lite_bootstrap/bootstrappers/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lite_bootstrap/bootstrappers/base.py b/lite_bootstrap/bootstrappers/base.py index b08bcc2..881e42e 100644 --- a/lite_bootstrap/bootstrappers/base.py +++ b/lite_bootstrap/bootstrappers/base.py @@ -136,7 +136,7 @@ def bootstrap(self) -> ApplicationT: msg = ( f"{type(self).__name__} was torn down and cannot bootstrap again: its instruments " f"already changed the application. To bootstrap again, construct a new " - f"{type(self).__name__} on a new application." + f"{type(self).__name__}, with a new application if it takes one." ) raise ConfigurationError(msg) if self.is_bootstrapped: