diff --git a/backend/src/infrastructure/app_factory.py b/backend/src/infrastructure/app_factory.py index 5c48ffcd..9cfd4206 100644 --- a/backend/src/infrastructure/app_factory.py +++ b/backend/src/infrastructure/app_factory.py @@ -53,15 +53,24 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: await set_threadpool_tokens() + # Teardown must only close what actually initialized: when startup fails + # midway, closing a never-initialized component raises from the finally + # block and masks the real startup error (e.g. an unreachable DB was + # reported as "Backend 'redis' is not available" from close_cache). + cache_initialized = False + rate_limiter_initialized = False + try: if isinstance(settings, DatabaseSettings) and create_tables_on_startup: await create_tables() if isinstance(settings, CacheSettings) and settings.CACHE_ENABLED: await initialize_cache() + cache_initialized = True if isinstance(settings, RateLimiterSettings) and settings.RATE_LIMITER_ENABLED: await initialize_rate_limiter() + rate_limiter_initialized = True await auth.initialize() @@ -72,10 +81,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: finally: await auth.shutdown() - if isinstance(settings, CacheSettings) and settings.CACHE_ENABLED: + if cache_initialized: await close_cache() - if isinstance(settings, RateLimiterSettings) and settings.RATE_LIMITER_ENABLED: + if rate_limiter_initialized: await close_rate_limiter() return lifespan diff --git a/backend/tests/unit/infrastructure/test_app_factory.py b/backend/tests/unit/infrastructure/test_app_factory.py new file mode 100644 index 00000000..c67467c0 --- /dev/null +++ b/backend/tests/unit/infrastructure/test_app_factory.py @@ -0,0 +1,29 @@ +"""Unit tests for the application lifespan factory.""" + +import pytest +from fastapi import FastAPI + +from src.infrastructure import app_factory +from src.infrastructure.config.settings import settings + + +@pytest.mark.asyncio +async def test_startup_failure_surfaces_the_original_error(monkeypatch): + """A failed startup must raise the failing step's exception, not a teardown one. + + Before the initialized-flags guard, a startup failure (e.g. an unreachable DB + in ``create_tables``) reached the ``finally`` block, where ``close_cache()`` + raised ``BackendNotFoundError`` for the never-initialized backend and masked + the real error at the bottom of the log. + """ + + async def failing_create_tables() -> None: + raise RuntimeError("db unreachable") + + monkeypatch.setattr(app_factory, "create_tables", failing_create_tables) + + lifespan = app_factory.lifespan_factory(settings, create_tables_on_startup=True) + + with pytest.raises(RuntimeError, match="db unreachable"): + async with lifespan(FastAPI()): + pass # pragma: no cover - startup fails before the yield