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(