Commit f8a4a16d authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: connect to PostgreSQL at startup and add readiness probe

parent 69a6deef
Loading
Loading
Loading
Loading
+26 −0
Changes for src/portal_service/api/dependencies.py: 26 added lines, 0 removed lines.
Original line number Diff line number Diff line
from typing import Annotated, cast

from fastapi import Depends, Request
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine

from portal_service.app_state import AppState


def get_app_state(request: Request) -> AppState:
    return cast(AppState, request.app.state)


def get_db_engine(request: Request) -> AsyncEngine:
    return get_app_state(request=request).db_engine


async def get_database_health(
    db_engine: Annotated[AsyncEngine, Depends(get_db_engine)],
) -> bool:
    try:
        async with db_engine.connect() as connection:
            await connection.execute(text("SELECT 1"))
        return True
    except Exception:
        return False
+17 −1
Changes for src/portal_service/api/health.py: 17 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -2,10 +2,15 @@
Health probes.

  GET /health/live   — process is up (liveness)
  GET /health/ready  — the database answers (readiness)

"""

from fastapi import APIRouter
from typing import Annotated

from fastapi import APIRouter, Depends, Response, status

from portal_service.api.dependencies import get_database_health

health_router = APIRouter(tags=["health"])

@@ -13,3 +18,14 @@ health_router = APIRouter(tags=["health"])
@health_router.get("/health/live")
async def live() -> dict[str, str]:
    return {"status": "alive"}


@health_router.get("/health/ready")
async def ready(
    response: Response,
    database_is_healthy: Annotated[bool, Depends(get_database_health)],
) -> dict[str, str]:
    if not database_is_healthy:
        response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
        return {"status": "unavailable"}
    return {"status": "ready"}
+8 −0
Changes for src/portal_service/app_state.py: 8 added lines, 0 removed lines.
Original line number Diff line number Diff line
from typing import Protocol

from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker


class AppState(Protocol):
    db_engine: AsyncEngine
    session_maker: async_sessionmaker[AsyncSession]
+22 −0
Changes for src/portal_service/main.py: 22 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -12,6 +12,10 @@ import structlog
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from portal_service.adapters.database.core import (
    build_engine_and_session_maker,
    schema_initialization,
)
from portal_service.api.exception_handlers import register_exception_handlers
from portal_service.api.health import health_router
from portal_service.api.middlewares import register_middlewares
@@ -28,7 +32,25 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
        security_mode=settings.auth.security_mode,
        version=settings.app_version,
    )

    try:
        db_engine, session_maker = await build_engine_and_session_maker(
            url=settings.postgresql_settings.url,
            echo=settings.postgresql_settings.echo,
        )
        if settings.postgresql_settings.create_schema_on_startup:
            await schema_initialization(db_engine)
        logger.info("database engine initialized")
    except Exception as e:
        logger.error("database engine init failed", error=str(e))
        raise

    app.state.db_engine = db_engine
    app.state.session_maker = session_maker

    yield

    await db_engine.dispose()
    logger.info("portal backend shutting down")


+10 −1
Changes for tests/test_misc.py: 10 added lines, 1 removed line.
Original line number Diff line number Diff line
"""Health (liveness) endpoint and request-id middleware."""
"""Health (liveness, readiness) endpoints and request-id middleware."""

from uuid import UUID

from fastapi import FastAPI
from fastapi.testclient import TestClient


@@ -11,6 +12,14 @@ def test_health_live(client_none: TestClient) -> None:
    assert r.json() == {"status": "alive"}


def test_health_ready_is_503_when_database_unreachable(app_none: FastAPI) -> None:
    # `with` runs the lifespan, which builds the engine (without connecting).
    with TestClient(app_none) as client:
        r = client.get("/health/ready")
    assert r.status_code == 503
    assert r.json() == {"status": "unavailable"}


def test_request_id_generated_when_absent(client_none: TestClient) -> None:
    r = client_none.get("/health/live")
    rid = r.headers["X-Request-ID"]