From a071074e103d3d050a1af80f8cb3b27255fb8d53 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 2 May 2026 18:09:00 +0300 Subject: [PATCH] fix: preserve teardown error detail via TeardownError exception Add lite_bootstrap.exceptions module with LiteBootstrapError(RuntimeError) base and BootstrapperNotReadyError, ConfigurationError, TeardownError subclasses. TeardownError aggregates every (instrument_name, exception) pair into both the message string and a public .errors attribute, so detail survives even when LoggingInstrument tears down before later instrument failures are logged. All four prior RuntimeError raise sites in the package migrate to the new tree; subclassing RuntimeError keeps existing `except RuntimeError:` callers working. Co-Authored-By: Claude Opus 4.7 --- lite_bootstrap/__init__.py | 10 +++++++ lite_bootstrap/bootstrappers/base.py | 13 ++++----- .../bootstrappers/fastapi_bootstrapper.py | 3 ++- lite_bootstrap/exceptions.py | 19 +++++++++++++ lite_bootstrap/helpers/fastapi_helpers.py | 3 ++- tests/test_free_bootstrap.py | 27 +++++++++++++++++-- 6 files changed, 65 insertions(+), 10 deletions(-) create mode 100644 lite_bootstrap/exceptions.py diff --git a/lite_bootstrap/__init__.py b/lite_bootstrap/__init__.py index fa026eb..42eb54f 100644 --- a/lite_bootstrap/__init__.py +++ b/lite_bootstrap/__init__.py @@ -2,18 +2,28 @@ from lite_bootstrap.bootstrappers.faststream_bootstrapper import FastStreamBootstrapper, FastStreamConfig from lite_bootstrap.bootstrappers.free_bootstrapper import FreeBootstrapper, FreeBootstrapperConfig from lite_bootstrap.bootstrappers.litestar_bootstrapper import LitestarBootstrapper, LitestarConfig +from lite_bootstrap.exceptions import ( + BootstrapperNotReadyError, + ConfigurationError, + LiteBootstrapError, + TeardownError, +) from lite_bootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument __all__ = [ + "BootstrapperNotReadyError", + "ConfigurationError", "FastAPIBootstrapper", "FastAPIConfig", "FastStreamBootstrapper", "FastStreamConfig", "FreeBootstrapper", "FreeBootstrapperConfig", + "LiteBootstrapError", "LitestarBootstrapper", "LitestarConfig", "PyroscopeConfig", "PyroscopeInstrument", + "TeardownError", ] diff --git a/lite_bootstrap/bootstrappers/base.py b/lite_bootstrap/bootstrappers/base.py index d50cabb..ca1b59d 100644 --- a/lite_bootstrap/bootstrappers/base.py +++ b/lite_bootstrap/bootstrappers/base.py @@ -3,6 +3,7 @@ import typing import warnings +from lite_bootstrap.exceptions import BootstrapperNotReadyError, TeardownError from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument from lite_bootstrap.types import ApplicationT @@ -27,7 +28,7 @@ def __init__(self, bootstrap_config: BaseConfig) -> None: self.is_bootstrapped = False if not self.is_ready(): msg = f"{type(self).__name__} is not ready: {self.not_ready_message}" - raise RuntimeError(msg) + raise BootstrapperNotReadyError(msg) self.bootstrap_config = bootstrap_config self.instruments = [] @@ -61,13 +62,13 @@ def bootstrap(self) -> ApplicationT: def teardown(self) -> None: self.is_bootstrapped = False - errors: list[BaseException] = [] + errors: list[tuple[str, BaseException]] = [] for one_instrument in reversed(self.instruments): try: one_instrument.teardown() except Exception as e: # noqa: BLE001, PERF203 - logger.warning(f"Error tearing down {type(one_instrument).__name__}: {e}") - errors.append(e) + name = type(one_instrument).__name__ + logger.warning(f"Error tearing down {name}: {e}") + errors.append((name, e)) if errors: - msg = f"{len(errors)} instrument(s) failed during teardown" - raise RuntimeError(msg) from errors[0] + raise TeardownError(errors) from errors[0][1] diff --git a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py index a3fe283..06eb04e 100644 --- a/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/fastapi_bootstrapper.py @@ -5,6 +5,7 @@ from lite_bootstrap import import_checker from lite_bootstrap.bootstrappers.base import BaseBootstrapper +from lite_bootstrap.exceptions import ConfigurationError from lite_bootstrap.helpers.fastapi_helpers import enable_offline_docs from lite_bootstrap.instruments.cors_instrument import CorsConfig, CorsInstrument from lite_bootstrap.instruments.healthchecks_instrument import ( @@ -56,7 +57,7 @@ class FastAPIConfig( def __post_init__(self) -> None: if not import_checker.is_fastapi_installed: msg = "fastapi is not installed" - raise RuntimeError(msg) + raise ConfigurationError(msg) if not self.application: object.__setattr__( diff --git a/lite_bootstrap/exceptions.py b/lite_bootstrap/exceptions.py new file mode 100644 index 0000000..400688a --- /dev/null +++ b/lite_bootstrap/exceptions.py @@ -0,0 +1,19 @@ +class LiteBootstrapError(RuntimeError): + """Base class for all lite-bootstrap errors.""" + + +class BootstrapperNotReadyError(LiteBootstrapError): + """Raised when a bootstrapper's is_ready() check fails during construction.""" + + +class ConfigurationError(LiteBootstrapError): + """Raised when a config is invalid or a required optional dependency is missing.""" + + +class TeardownError(LiteBootstrapError): + """Raised when one or more instruments fail during teardown.""" + + def __init__(self, errors: list[tuple[str, BaseException]]) -> None: + self.errors = errors + details = "; ".join(f"{name}: {err}" for name, err in errors) + super().__init__(f"{len(errors)} instrument(s) failed during teardown: {details}") diff --git a/lite_bootstrap/helpers/fastapi_helpers.py b/lite_bootstrap/helpers/fastapi_helpers.py index e9af70b..2fe2fd4 100644 --- a/lite_bootstrap/helpers/fastapi_helpers.py +++ b/lite_bootstrap/helpers/fastapi_helpers.py @@ -2,6 +2,7 @@ import typing from lite_bootstrap import import_checker +from lite_bootstrap.exceptions import ConfigurationError if import_checker.is_fastapi_installed: @@ -18,7 +19,7 @@ def enable_offline_docs( ) -> None: if not (app_openapi_url := app.openapi_url): msg = "No app.openapi_url specified" - raise RuntimeError(msg) + raise ConfigurationError(msg) docs_url: str = app.docs_url or "/docs" redoc_url: str = app.redoc_url or "/redoc" diff --git a/tests/test_free_bootstrap.py b/tests/test_free_bootstrap.py index 714cd9f..708a3b8 100644 --- a/tests/test_free_bootstrap.py +++ b/tests/test_free_bootstrap.py @@ -4,7 +4,7 @@ import structlog from structlog.testing import capture_logs -from lite_bootstrap import FreeBootstrapper, FreeBootstrapperConfig +from lite_bootstrap import FreeBootstrapper, FreeBootstrapperConfig, TeardownError from tests.conftest import CustomInstrumentor, SentryTestTransport, emulate_package_missing @@ -59,13 +59,36 @@ def test_teardown_error_isolation(free_bootstrapper_config: FreeBootstrapperConf good = MagicMock() bootstrapper.instruments = [bad, good] - with capture_logs() as cap_logs, pytest.raises(RuntimeError, match="1 instrument"): + with capture_logs() as cap_logs, pytest.raises(TeardownError, match="boom") as excinfo: bootstrapper.teardown() # Both instruments attempted teardown despite the error (LIFO: good first, bad second). good.teardown.assert_called_once() bad.teardown.assert_called_once() assert any("boom" in entry.get("event", "") for entry in cap_logs) + assert excinfo.value.errors == [("MagicMock", excinfo.value.__cause__)] + + +def test_teardown_error_aggregates_all_failures(free_bootstrapper_config: FreeBootstrapperConfig) -> None: + bootstrapper = FreeBootstrapper(bootstrap_config=free_bootstrapper_config) + bootstrapper.bootstrap() + + first = MagicMock() + first.teardown.side_effect = RuntimeError("boom-1") + second = MagicMock() + second.teardown.side_effect = ValueError("boom-2") + bootstrapper.instruments = [first, second] + + with pytest.raises(TeardownError) as excinfo: + bootstrapper.teardown() + + msg = str(excinfo.value) + assert "2 instrument(s) failed during teardown" in msg + assert "boom-1" in msg + assert "boom-2" in msg + # `second` runs first under reversed(), so its ValueError is the chained cause. + assert isinstance(excinfo.value.__cause__, ValueError) + assert [name for name, _ in excinfo.value.errors] == ["MagicMock", "MagicMock"] @pytest.mark.parametrize(