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
100 changes: 91 additions & 9 deletions Backend/app/spotify_electron/health/health_controller.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,103 @@
"""
Health controller for handling incoming HTTP Requests
"""
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from typing import Dict
from starlette.status import HTTP_200_OK, HTTP_503_SERVICE_UNAVAILABLE

from fastapi import APIRouter
from fastapi.responses import Response
from starlette.status import HTTP_200_OK
from app.auth.auth_schema import AuthConfig
from app.database.DatabaseConnectionManager import DatabaseConnectionManager
from app.spotify_electron.song.providers.song_service_provider import SongServiceProvider
from app.logging.logging_schema import SpotifyElectronLogger
from app.logging.logging_constants import LOGGING_HEALTH

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

class HealthCheckResponse(BaseModel):
status: str
Copy link
Owner

Choose a reason for hiding this comment

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

Instead of status:str we can create a Enum that encapsulates different status. With that we can make the code more reusable and concise

details: Dict[str, Dict[str, str]]

@router.get("/", summary="Health Check Endpoint")
def get_health() -> Response:
"""Validates if the app has launched correctly
async 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 logic belongs to a service layer more than a controller one. Move functions that handle logic to health_service

try:
db_client = DatabaseConnectionManager.get_database_client()
await db_client.admin.command('ping')
return {"status": "healthy", "message": "Database connection is active"}
except Exception as e:
logger.error(f"Database health check failed: {str(e)}")
return {"status": "unhealthy", "message": f"Database connection failed: {str(e)}"}

def check_song_service() -> Dict[str, str]:
try:
service = SongServiceProvider.get_service()
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": "SongService is not initialized"}
return {"status": "healthy", "message": "SongService is properly initialized"}
except Exception as e:
logger.error(f"SongService health check failed: {str(e)}")
return {"status": "unhealthy", "message": f"SongService check failed: {str(e)}"}

def check_auth_config() -> Dict[str, str]:
try:
if not all([
hasattr(AuthConfig, 'SIGNING_SECRET_KEY') and AuthConfig.SIGNING_SECRET_KEY,
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"
}

return {
"status": "healthy",
"message": "Auth configuration is properly initialized"
}
except Exception as e:
logger.error(f"Auth configuration health check failed: {str(e)}")
return {"status": "unhealthy", "message": f"Auth configuration check failed: {str(e)}"}

@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
-------
Response 200 OK

JSONResponse with health status and details of each component
200 OK if all components are healthy
503 Service Unavailable if any component is unhealthy
"""
return Response(status_code=HTTP_200_OK, content="OK", media_type="text/plain")
db_health = await 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
)
96 changes: 92 additions & 4 deletions Backend/tests/test__health.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,106 @@
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

client = TestClient(app)


@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.health_controller.check_database_connection", return_value=mock_check_database_connection())
mocker.patch("app.health_controller.check_song_service", return_value=mock_check_song_service())
mocker.patch("app.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.health_controller.check_database_connection", return_value={
"status": "unhealthy",
"message": "Mocked database connection failed"
})
mocker.patch("app.health_controller.check_song_service", return_value=mock_check_song_service())
mocker.patch("app.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.health_controller.check_database_connection", return_value=mock_check_database_connection())
mocker.patch("app.health_controller.check_song_service", return_value={
"status": "unhealthy",
"message": "Mocked SongService initialization failed"
})
mocker.patch("app.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.health_controller.check_database_connection", return_value=mock_check_database_connection())
mocker.patch("app.health_controller.check_song_service", return_value=mock_check_song_service())
mocker.patch("app.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"