From f7583c9943cc30d420c4a5a80822db9966854bbe Mon Sep 17 00:00:00 2001 From: Savitha M Date: Fri, 26 Sep 2025 04:45:35 +0000 Subject: [PATCH] feat(core): add robust health check endpoint with modular service checks --- core/tests/__init__.py | 0 core/tests/test_health_check.py | 132 ++++++++++++++++++++++++++++++++ core/urls.py | 3 +- core/views.py | 111 ++++++++++++++++++++++++++- 4 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 core/tests/__init__.py create mode 100644 core/tests/test_health_check.py diff --git a/core/tests/__init__.py b/core/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/tests/test_health_check.py b/core/tests/test_health_check.py new file mode 100644 index 0000000..45fe139 --- /dev/null +++ b/core/tests/test_health_check.py @@ -0,0 +1,132 @@ +from django.test import TestCase +from rest_framework.test import APITestCase +from rest_framework import status +from unittest.mock import patch, MagicMock +from django.conf import settings +import os + +from core.views import HealthCheckView + + +class HealthCheckViewTest(APITestCase): + def setUp(self): + self.url = '/core/health/' + # Ensure no OpenAI key for some tests + if 'OPENAI_API_KEY' in os.environ: + del os.environ['OPENAI_API_KEY'] + + @patch('core.views.connection.cursor') + @patch('core.views.Redis.from_url') + @patch('core.views.OpenAI') + def test_health_check_all_healthy(self, mock_openai, mock_redis, mock_cursor): + # Mock DB success + mock_cursor_instance = MagicMock() + mock_cursor.return_value.__enter__.return_value = mock_cursor_instance + mock_cursor_instance.execute.return_value = None + + # Mock Redis success + mock_redis_instance = MagicMock() + mock_redis_instance.ping.return_value = True + mock_redis.return_value = mock_redis_instance + + # Mock OpenAI success (key set) + os.environ['OPENAI_API_KEY'] = 'test-key' + mock_client = MagicMock() + mock_client.models.list.return_value = MagicMock() + mock_openai.return_value = mock_client + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data['overall'], 'healthy') + self.assertEqual(data['services']['db'], 'healthy') + self.assertEqual(data['services']['redis'], 'healthy') + self.assertEqual(data['services']['openai'], 'healthy') + + @patch('core.views.connection.cursor') + @patch('core.views.Redis.from_url') + def test_health_check_openai_skipped(self, mock_redis, mock_cursor): + # Mock DB and Redis success + mock_cursor_instance = MagicMock() + mock_cursor.return_value.__enter__.return_value = mock_cursor_instance + mock_cursor_instance.execute.return_value = None + + mock_redis_instance = MagicMock() + mock_redis_instance.ping.return_value = True + mock_redis.return_value = mock_redis_instance + + # No OpenAI key + if 'OPENAI_API_KEY' in os.environ: + del os.environ['OPENAI_API_KEY'] + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data['overall'], 'healthy') + self.assertEqual(data['services']['db'], 'healthy') + self.assertEqual(data['services']['redis'], 'healthy') + self.assertEqual(data['services']['openai'], 'skipped') + + @patch('core.views.connection.cursor') + @patch('core.views.Redis.from_url') + @patch('core.views.OpenAI') + def test_health_check_openai_unhealthy(self, mock_openai, mock_redis, mock_cursor): + # Mock DB and Redis success + mock_cursor_instance = MagicMock() + mock_cursor.return_value.__enter__.return_value = mock_cursor_instance + mock_cursor_instance.execute.return_value = None + + mock_redis_instance = MagicMock() + mock_redis_instance.ping.return_value = True + mock_redis.return_value = mock_redis_instance + + # Mock OpenAI failure + os.environ['OPENAI_API_KEY'] = 'test-key' + mock_openai.side_effect = Exception('API error') + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data['overall'], 'healthy') + self.assertEqual(data['services']['db'], 'healthy') + self.assertEqual(data['services']['redis'], 'healthy') + self.assertEqual(data['services']['openai'], 'unhealthy') + + @patch('core.views.connection.cursor') + @patch('core.views.Redis.from_url') + def test_health_check_db_unhealthy(self, mock_redis, mock_cursor): + # Mock DB failure + mock_cursor.side_effect = Exception('DB error') + + # Mock Redis success + mock_redis_instance = MagicMock() + mock_redis_instance.ping.return_value = True + mock_redis.return_value = mock_redis_instance + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) + data = response.json() + self.assertEqual(data['overall'], 'unhealthy') + self.assertEqual(data['services']['db'], 'unhealthy') + + @patch('core.views.connection.cursor') + @patch('core.views.Redis.from_url') + def test_health_check_redis_unhealthy(self, mock_redis, mock_cursor): + # Mock DB success + mock_cursor_instance = MagicMock() + mock_cursor.return_value.__enter__.return_value = mock_cursor_instance + mock_cursor_instance.execute.return_value = None + + # Mock Redis failure + mock_redis.side_effect = Exception('Redis error') + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) + data = response.json() + self.assertEqual(data['overall'], 'unhealthy') + self.assertEqual(data['services']['redis'], 'unhealthy') diff --git a/core/urls.py b/core/urls.py index 6d00c40..667df5a 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,10 +1,11 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from core.views import CreateCheckoutSessionView, StripeWebhookView, PlanListView, CreateBillingPortalSessionView, VerifyCheckoutSessionView +from core.views import CreateCheckoutSessionView, StripeWebhookView, PlanListView, CreateBillingPortalSessionView, VerifyCheckoutSessionView, HealthCheckView router = DefaultRouter() urlpatterns = [ + path('health/', HealthCheckView.as_view(), name='health_check'), path('payments/create-checkout-session/', CreateCheckoutSessionView.as_view(), name='create_checkout_session'), path('payments/webhook/stripe/', StripeWebhookView.as_view(), name='stripe_webhook'), path('payments/verify-session/', VerifyCheckoutSessionView.as_view(), name='verify_checkout_session'), diff --git a/core/views.py b/core/views.py index a6a4e40..3265add 100644 --- a/core/views.py +++ b/core/views.py @@ -17,6 +17,10 @@ from django.core.cache import cache from dj_rest_auth.registration.views import SocialLoginView from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter +from django.db import connection +from redis import Redis +from openai import OpenAI +import os import stripe import logging @@ -377,4 +381,109 @@ def get(self, request): except Exception as e: logger.error(f"❌ Error verifying Stripe session {session_id}: {e}") - return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) \ No newline at end of file + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@extend_schema( + responses={ + 200: { + 'type': 'object', + 'properties': { + 'overall': {'type': 'string'}, + 'services': { + 'type': 'object', + 'properties': { + 'db': {'type': 'string'}, + 'redis': {'type': 'string'}, + 'openai': {'type': 'string'}, + } + } + } + }, + 503: { + 'type': 'object', + 'properties': { + 'overall': {'type': 'string'}, + 'services': { + 'type': 'object', + 'properties': { + 'db': {'type': 'string'}, + 'redis': {'type': 'string'}, + 'openai': {'type': 'string'}, + } + } + } + }, + }, + examples=[ + OpenApiExample( + name="Healthy services", + value={ + "overall": "healthy", + "services": { + "db": "healthy", + "redis": "healthy", + "openai": "healthy" + } + }, + response_only=True + ), + OpenApiExample( + name="OpenAI skipped", + value={ + "overall": "healthy", + "services": { + "db": "healthy", + "redis": "healthy", + "openai": "skipped" + } + }, + response_only=True + ) + ] +) +class HealthCheckView(APIView): + permission_classes = [AllowAny] + + def get(self, request): + services = {} + + # Check DB + try: + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + services['db'] = 'healthy' + except Exception as e: + logger.error(f"DB health check failed: {e}") + services['db'] = 'unhealthy' + + # Check Redis + try: + redis_client = Redis.from_url(settings.CACHE_URL) + redis_client.ping() + services['redis'] = 'healthy' + except Exception as e: + logger.error(f"Redis health check failed: {e}") + services['redis'] = 'unhealthy' + + # Check OpenAI (optional) + api_key = os.getenv("OPENAI_API_KEY") + if api_key: + try: + client = OpenAI(api_key=api_key) + client.models.list() # Lightweight check + services['openai'] = 'healthy' + except Exception as e: + logger.error(f"OpenAI health check failed: {e}") + services['openai'] = 'unhealthy' + else: + services['openai'] = 'skipped' + + # Determine overall status + overall = 'healthy' if services['db'] == 'healthy' and services['redis'] == 'healthy' else 'unhealthy' + http_status = status.HTTP_200_OK if overall == 'healthy' else status.HTTP_503_SERVICE_UNAVAILABLE + + return Response({ + 'overall': overall, + 'services': services + }, status=http_status)