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
10 changes: 10 additions & 0 deletions lite_bootstrap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
13 changes: 7 additions & 6 deletions lite_bootstrap/bootstrappers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 = []
Expand Down Expand Up @@ -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]
3 changes: 2 additions & 1 deletion lite_bootstrap/bootstrappers/fastapi_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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__(
Expand Down
19 changes: 19 additions & 0 deletions lite_bootstrap/exceptions.py
Original file line number Diff line number Diff line change
@@ -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}")
3 changes: 2 additions & 1 deletion lite_bootstrap/helpers/fastapi_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import typing

from lite_bootstrap import import_checker
from lite_bootstrap.exceptions import ConfigurationError


if import_checker.is_fastapi_installed:
Expand All @@ -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"
Expand Down
27 changes: 25 additions & 2 deletions tests/test_free_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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(
Expand Down
Loading