Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#283: Improve health endpoint with missing required conditions #291

Closed
37 changes: 32 additions & 5 deletions Backend/app/database/DatabaseConnectionManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
from pymongo.collection import Collection

from app.common.app_schema import AppEnvironmentMode
from app.database.database_schema import BaseDatabaseConnection, DatabaseCollection
from app.database.database_schema import (
BaseDatabaseConnection,
DatabaseCollection,
DatabasePingFailedException,
)
from app.database.DatabaseProductionConnection import DatabaseProductionConnection
from app.database.DatabaseTestingConnection import DatabaseTestingConnection
from app.logging.logging_constants import LOGGING_DATABASE_MANAGER
Expand Down Expand Up @@ -41,15 +45,38 @@ def get_collection_connection(cls, collection_name: DatabaseCollection) -> Colle
def init_database_connection(
cls, environment: AppEnvironmentMode, connection_uri: str
) -> None:
"""Initializes the database connection and loads its unique instance\
based on current environment value
"""
Initializes the database connection.

Args:
environment (AppEnvironmentMode): the current environment value
connection_uri (str): the database connection uri
environment (AppEnvironmentMode): The current environment mode.
connection_uri (str): The URI for connecting to the database.

Returns:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no need to add docs for returning None. The extension njpwerner.autodocstring handles it like that and I think Pycharm too

None
"""
from pymongo import MongoClient
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The purpose of this class is not having any initialization of the Database until we know if tests will be run or we're in production mode. database_connection_class contains the logic associated to a concrete database, being a mock connectior or a real one. We're also abstracting the logic behind the DatabaseConnection classes, DatabaseConnectionManager is only managing that instance and showing it to the rest of the app, concrete logic should be inside DatabaseConnection classes :). Feel free to ask if something isn't explained correctly


database_connection_class = cls.database_connection_mapping.get(
environment, DatabaseProductionConnection
)
database_connection_class.init_connection(connection_uri)
cls.connection = database_connection_class
cls.client = MongoClient(connection_uri)

@classmethod
def ping_database(cls) -> bool:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should move this to the concrete implementations of database connection, such as Prod and Testing, included in the same folder

"""Pings the database to check the connection

Raises:
DatabasePingFailedException: if ping has failed

Returns:
bool: True if the database is reachable, False otherwise
"""
try:
cls.client.admin.command("ping")
except DatabasePingFailedException:
return False
else:
return True
1 change: 1 addition & 0 deletions Backend/app/logging/logging_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,4 @@

# Utilities
LOGGING_AUDIO_MANAGEMENT_UTILS = "AUDIO_MANAGEMENT_UTILS"
LOGGING_HEALTH = "HEALTH"
118 changes: 109 additions & 9 deletions Backend/app/spotify_electron/health/health_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,119 @@
"""

from fastapi import APIRouter
from fastapi.responses import Response
from starlette.status import HTTP_200_OK
from fastapi.responses import JSONResponse
from starlette.status import HTTP_200_OK, HTTP_503_SERVICE_UNAVAILABLE

from app.auth.auth_schema import AuthConfig
from app.database.database_schema import DatabasePingFailedException
from app.database.DatabaseConnectionManager import DatabaseConnectionManager
from app.logging.logging_constants import LOGGING_HEALTH
from app.logging.logging_schema import SpotifyElectronLogger
from app.spotify_electron.health.health_schema import HealthCheckResponse
from app.spotify_electron.song.providers.song_service_provider import SongServiceProvider

router = APIRouter(prefix="/health", tags=["health"])
logger = SpotifyElectronLogger(LOGGING_HEALTH).getLogger()


def check_database_connection() -> dict[str, str]:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can return None if the if statement is not met. We should change ping database to throw an exception if ping couldn't be done instead of returning a bool. I still feel like this func belongs more to a service layer.

"""Validates ping to database

Returns:
dict[str, str]: Dictionary with status and explanatory message
"""
try:
if DatabaseConnectionManager.ping_database():
return {"status": "healthy", "message": "Database connection is active"}
except DatabasePingFailedException:
logger.exception(DatabasePingFailedException.ERROR)
else:
return {"status": "unhealthy", "message": DatabasePingFailedException.ERROR}


def check_song_service() -> dict[str, str]:
"""Validates if SongServiceProvider is inited

Returns:
dict[str, str]: Dictionary with status and explanatory message
"""
try:
service = SongServiceProvider.get_song_service()
except Exception as exception:
logger.exception("SongServiceProvider health check failed")
return {
"status": "unhealthy",
"message": f"SongServiceProvider check failed: {str(exception)}",
}
else:
if service is None:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be done in a single return x if y else z statement

return {"status": "unhealthy", "message": "SongServiceProvider is not initialized"}
return {"status": "healthy", "message": "SongServiceProvider is properly initialized"}


@router.get("/", summary="Health Check Endpoint")
def get_health() -> Response:
"""Validates if the app has launched correctly
def check_auth_config() -> dict[str, str]:
"""Validates if auth configurations are set

Returns
-------
Response 200 OK
Returns:
dict[str, str]: Dictionary with status and explanatory message
"""
try:
if not all(
[
hasattr(AuthConfig, "VERTIFICATION_ALGORITHM")
and AuthConfig.VERTIFICATION_ALGORITHM,
hasattr(AuthConfig, "ACCESS_TOKEN_EXPIRE_MINUTES")
and AuthConfig.ACCESS_TOKEN_EXPIRE_MINUTES,
hasattr(AuthConfig, "DAYS_TO_EXPIRE_COOKIE")
and AuthConfig.DAYS_TO_EXPIRE_COOKIE,
]
):
return {
"status": "unhealthy",
"message": "Auth configuration is not fully initialized",
}
except Exception:
logger.exception("Auth configuration health check failed")
return {
"status": "unhealthy",
"message": "Auth configuration check failed",
}
else:
return {"status": "healthy", "message": "Auth configuration is properly initialized"}


@router.get(
"/",
response_model=HealthCheckResponse,
summary="Health Check Endpoint",
description="Validates if the app and its critical components are functioning correctly",
)
async def get_health() -> JSONResponse:
"""Validates if the app has launched correctly and all critical components are healthy

Returns:
JSONResponse: health status with details of each component and status code
"""
return Response(status_code=HTTP_200_OK, content="OK", media_type="text/plain")
db_health = check_database_connection()
service_health = check_song_service()
auth_health = check_auth_config()

health_details = {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a class

"database": db_health,
"song_service": service_health,
"auth_config": auth_health,
}

is_healthy = all(check["status"] == "healthy" for check in health_details.values())

response_data = {
"status": "healthy" if is_healthy else "unhealthy",
"details": health_details,
}

status_code = HTTP_200_OK if is_healthy else HTTP_503_SERVICE_UNAVAILABLE

if not is_healthy:
logger.warning(f"Health check failed: {health_details}")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont think this is needed, we're going to return 503 if healthcheck failed


return JSONResponse(content=response_data, status_code=status_code)
15 changes: 15 additions & 0 deletions Backend/app/spotify_electron/health/health_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""
This module defines the schema for health check responses.

It includes the `HealthCheckResponse` model, which represents the structure
of responses for system health checks.
"""

from pydantic import BaseModel


class HealthCheckResponse(BaseModel):
"""Class that represents response of health check"""

status: str
details: dict[str, dict[str, str]]
137 changes: 134 additions & 3 deletions Backend/tests/test__health.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from fastapi.testclient import TestClient
from pytest import fixture
from starlette.status import HTTP_200_OK
from starlette.status import HTTP_200_OK, HTTP_503_SERVICE_UNAVAILABLE

from app.__main__ import app

Expand All @@ -9,10 +9,141 @@

@fixture(scope="module", autouse=True)
def set_up(trigger_app_startup):
"""Setup fixture to initialize the app."""
pass


def test_health_check():
def mock_check_database_connection():
"""Mock database connection check."""
return {"status": "healthy", "message": "Mocked database connection is active"}


def mock_check_song_service():
"""Mock song service check."""
return {"status": "healthy", "message": "Mocked SongService is properly initialized"}


def mock_check_auth_config():
"""Mock auth config check."""
return {
"status": "healthy",
"message": "Mocked Auth configuration is properly initialized",
}


def test_health_check_all_healthy(mocker):
"""Test health check endpoint when all components are healthy."""
# Mock dependencies
mocker.patch(
"app.spotify_electron.health.health_controller.check_database_connection",
return_value=mock_check_database_connection(),
)
mocker.patch(
"app.spotify_electron.health.health_controller.check_song_service",
return_value=mock_check_song_service(),
)
mocker.patch(
"app.spotify_electron.health.health_controller.check_auth_config",
return_value=mock_check_auth_config(),
)

response = client.get("/health/")
assert response.status_code == HTTP_200_OK
assert response.text == "OK"
response_json = response.json()

assert response_json["status"] == "healthy"
assert response_json["details"]["database"]["status"] == "healthy"
assert response_json["details"]["song_service"]["status"] == "healthy"
assert response_json["details"]["auth_config"]["status"] == "healthy"


def test_health_check_unhealthy_database(mocker):
"""Test health check endpoint when the database connection is unhealthy."""
# Mock dependencies
mocker.patch(
"app.spotify_electron.health.health_controller.check_database_connection",
return_value={"status": "unhealthy", "message": "Mocked database connection failed"},
)
mocker.patch(
"app.spotify_electron.health.health_controller.check_song_service",
return_value=mock_check_song_service(),
)
mocker.patch(
"app.spotify_electron.health.health_controller.check_auth_config",
return_value=mock_check_auth_config(),
)

response = client.get("/health/")
assert response.status_code == HTTP_503_SERVICE_UNAVAILABLE
response_json = response.json()

assert response_json["status"] == "unhealthy"
assert response_json["details"]["database"]["status"] == "unhealthy"
assert (
response_json["details"]["database"]["message"] == "Mocked database connection failed"
)
assert response_json["details"]["song_service"]["status"] == "healthy"
assert response_json["details"]["auth_config"]["status"] == "healthy"


def test_health_check_unhealthy_song_service(mocker):
"""Test health check endpoint when the SongService is unhealthy."""
# Mock dependencies
mocker.patch(
"app.spotify_electron.health.health_controller.check_database_connection",
return_value=mock_check_database_connection(),
)
mocker.patch(
"app.spotify_electron.health.health_controller.check_song_service",
return_value={
"status": "unhealthy",
"message": "Mocked SongService initialization failed",
},
)
mocker.patch(
"app.spotify_electron.health.health_controller.check_auth_config",
return_value=mock_check_auth_config(),
)

response = client.get("/health/")
assert response.status_code == HTTP_503_SERVICE_UNAVAILABLE
response_json = response.json()

assert response_json["status"] == "unhealthy"
assert response_json["details"]["song_service"]["status"] == "unhealthy"
assert (
response_json["details"]["song_service"]["message"]
== "Mocked SongService initialization failed"
)
assert response_json["details"]["database"]["status"] == "healthy"
assert response_json["details"]["auth_config"]["status"] == "healthy"


def test_health_check_unhealthy_auth_config(mocker):
"""Test health check endpoint when the Auth configuration is unhealthy."""
# Mock dependencies
mocker.patch(
"app.spotify_electron.health.health_controller.check_database_connection",
return_value=mock_check_database_connection(),
)
mocker.patch(
"app.spotify_electron.health.health_controller.check_song_service",
return_value=mock_check_song_service(),
)
mocker.patch(
"app.spotify_electron.health.health_controller.check_auth_config",
return_value={"status": "unhealthy", "message": "Mocked Auth configuration failed"},
)

response = client.get("/health/")
assert response.status_code == HTTP_503_SERVICE_UNAVAILABLE
response_json = response.json()

assert response_json["status"] == "unhealthy"
assert response_json["details"]["auth_config"]["status"] == "unhealthy"
assert (
response_json["details"]["auth_config"]["message"]
== "Mocked Auth configuration failed"
)
assert response_json["details"]["database"]["status"] == "healthy"
assert response_json["details"]["song_service"]["status"] == "healthy"