Skip to content
Open
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
13 changes: 11 additions & 2 deletions backend/src/infrastructure/app_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions backend/tests/unit/infrastructure/test_app_factory.py
Original file line number Diff line number Diff line change
@@ -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
Loading