diff --git a/conf/api.yaml b/conf/api.yaml index 46dc63d..b1f2b1d 100644 --- a/conf/api.yaml +++ b/conf/api.yaml @@ -38,6 +38,41 @@ paths: '303': description: Redirect to actual address of Loing service which performs auth up to its capabilities + /health: + get: + summary: Service health check + description: Service health and dependency status check + responses: + '200': + description: Service is healthy + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + uptime_seconds: + type: integer + example: 12345 + '503': + description: Service is degraded + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: degraded + details: + type: object + additionalProperties: + type: string + example: + kafka: not_initialized + /topics: get: summary: Get a list of topics diff --git a/src/event_gate_lambda.py b/src/event_gate_lambda.py index a0fcefa..38f0088 100644 --- a/src/event_gate_lambda.py +++ b/src/event_gate_lambda.py @@ -26,6 +26,7 @@ from src.handlers.handler_token import HandlerToken from src.handlers.handler_topic import HandlerTopic +from src.handlers.handler_health import HandlerHealth from src.utils.constants import SSL_CA_BUNDLE_KEY from src.utils.utils import build_error_response from src.writers import writer_eventbridge, writer_kafka, writer_postgres @@ -85,6 +86,9 @@ # Initialize topic handler and load topic schemas handler_topic = HandlerTopic(CONF_DIR, ACCESS, handler_token).load_topic_schemas() +# Initialize health handler +handler_health = HandlerHealth(logger, config) + def get_api() -> Dict[str, Any]: """Return the OpenAPI specification text.""" @@ -108,6 +112,8 @@ def lambda_handler(event: Dict[str, Any], _context: Any = None) -> Dict[str, Any return get_api() if resource == "/token": return handler_token.get_token_provider_info() + if resource == "/health": + return handler_health.get_health() if resource == "/topics": return handler_topic.get_topics_list() if resource == "/topics/{topic_name}": diff --git a/src/handlers/handler_health.py b/src/handlers/handler_health.py new file mode 100644 index 0000000..4666ce8 --- /dev/null +++ b/src/handlers/handler_health.py @@ -0,0 +1,101 @@ +# +# Copyright 2025 ABSA Group Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +This module provides the HandlerHealth class for service health monitoring. +""" +import json +import logging +from datetime import datetime, timezone +from typing import Dict, Any + +from src.writers import writer_eventbridge, writer_kafka, writer_postgres + + +class HandlerHealth: + """ + HandlerHealth manages service health checks and dependency status monitoring. + """ + + def __init__(self, logger_instance: logging.Logger, config: Dict[str, Any]): + """ + Initialize the health handler. + + Args: + logger_instance: Shared application logger. + config: Configuration dictionary. + """ + self.logger = logger_instance + self.config = config + self.start_time = datetime.now(timezone.utc) + + def get_health(self) -> Dict[str, Any]: + """ + Check service health and return status. + + Performs lightweight dependency checks by verifying that writer STATE + dictionaries are properly initialized with required keys. + + Returns: + Dict[str, Any]: API Gateway response with health status. + - 200: All dependencies healthy + - 503: One or more dependencies not initialized + """ + self.logger.debug("Handling GET Health") + + details: Dict[str, str] = {} + all_healthy = True + + # Check Kafka writer STATE + kafka_state = writer_kafka.STATE + if not all(key in kafka_state for key in ["logger", "producer"]): + details["kafka"] = "not_initialized" + all_healthy = False + self.logger.debug("Kafka writer not properly initialized") + + # Check EventBridge writer STATE + eventbridge_state = writer_eventbridge.STATE + if not all(key in eventbridge_state for key in ["logger", "client", "event_bus_arn"]): + details["eventbridge"] = "not_initialized" + all_healthy = False + self.logger.debug("EventBridge writer not properly initialized") + + # Check PostgreSQL writer - it uses global logger variable and POSTGRES dict + # Just verify the module is accessible (init is always called in event_gate_lambda) + try: + _ = writer_postgres.logger + except AttributeError: + details["postgres"] = "not_initialized" + all_healthy = False + self.logger.debug("PostgreSQL writer not accessible") + + # Calculate uptime + uptime_seconds = int((datetime.now(timezone.utc) - self.start_time).total_seconds()) + + if all_healthy: + self.logger.debug("Health check passed - all dependencies healthy") + return { + "statusCode": 200, + "headers": {"Content-Type": "application/json"}, + "body": json.dumps({"status": "ok", "uptime_seconds": uptime_seconds}), + } + + self.logger.debug("Health check degraded - some dependencies not initialized: %s", details) + return { + "statusCode": 503, + "headers": {"Content-Type": "application/json"}, + "body": json.dumps({"status": "degraded", "details": details}), + } diff --git a/tests/handlers/test_handler_health.py b/tests/handlers/test_handler_health.py new file mode 100644 index 0000000..d15fc7d --- /dev/null +++ b/tests/handlers/test_handler_health.py @@ -0,0 +1,158 @@ +# +# Copyright 2025 ABSA Group Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import json +from unittest.mock import MagicMock, patch +import logging + +from src.handlers.handler_health import HandlerHealth + + +## get_health() - healthy state +def test_get_health_all_dependencies_healthy(): + """Health check returns 200 when all writer STATEs are properly initialized.""" + logger = logging.getLogger("test") + config = {} + handler = HandlerHealth(logger, config) + + # Mock all writers as healthy + with ( + patch("src.handlers.handler_health.writer_kafka.STATE", {"logger": logger, "producer": MagicMock()}), + patch( + "src.handlers.handler_health.writer_eventbridge.STATE", + { + "logger": logger, + "client": MagicMock(), + "event_bus_arn": "arn:aws:events:us-east-1:123456789012:event-bus/my-bus", + }, + ), + patch("src.handlers.handler_health.writer_postgres.logger", logger), + ): + response = handler.get_health() + + assert response["statusCode"] == 200 + body = json.loads(response["body"]) + assert body["status"] == "ok" + assert "uptime_seconds" in body + assert isinstance(body["uptime_seconds"], int) + assert body["uptime_seconds"] >= 0 + + +## get_health() - degraded state - kafka +def test_get_health_kafka_not_initialized(): + """Health check returns 503 when Kafka writer is not initialized.""" + logger = logging.getLogger("test") + config = {} + handler = HandlerHealth(logger, config) + + # Mock Kafka as not initialized (missing producer key) + with ( + patch("src.handlers.handler_health.writer_kafka.STATE", {"logger": logger}), + patch( + "src.handlers.handler_health.writer_eventbridge.STATE", + {"logger": logger, "client": MagicMock(), "event_bus_arn": "arn"}, + ), + patch("src.handlers.handler_health.writer_postgres.logger", logger), + ): + response = handler.get_health() + + assert response["statusCode"] == 503 + body = json.loads(response["body"]) + assert body["status"] == "degraded" + assert "details" in body + assert "kafka" in body["details"] + assert body["details"]["kafka"] == "not_initialized" + + +## get_health() - degraded state - eventbridge +def test_get_health_eventbridge_not_initialized(): + """Health check returns 503 when EventBridge writer is not initialized.""" + logger = logging.getLogger("test") + config = {} + handler = HandlerHealth(logger, config) + + # Mock EventBridge as not initialized (missing client key) + with ( + patch("src.handlers.handler_health.writer_kafka.STATE", {"logger": logger, "producer": MagicMock()}), + patch("src.handlers.handler_health.writer_eventbridge.STATE", {"logger": logger}), + patch("src.handlers.handler_health.writer_postgres.logger", logger), + ): + response = handler.get_health() + + assert response["statusCode"] == 503 + body = json.loads(response["body"]) + assert body["status"] == "degraded" + assert "eventbridge" in body["details"] + assert body["details"]["eventbridge"] == "not_initialized" + + +## get_health() - degraded state - multiple failures +def test_get_health_multiple_dependencies_not_initialized(): + """Health check returns 503 when multiple writers are not initialized.""" + logger = logging.getLogger("test") + config = {} + handler = HandlerHealth(logger, config) + + # Mock multiple writers as not initialized + with ( + patch("src.handlers.handler_health.writer_kafka.STATE", {}), + patch("src.handlers.handler_health.writer_eventbridge.STATE", {}), + patch("src.handlers.handler_health.writer_postgres", spec=[]), # spec=[] makes logger not exist + ): + response = handler.get_health() + + assert response["statusCode"] == 503 + body = json.loads(response["body"]) + assert body["status"] == "degraded" + assert len(body["details"]) >= 2 # At least kafka and eventbridge + assert "kafka" in body["details"] + assert "eventbridge" in body["details"] + + +## get_health() - uptime calculation +def test_get_health_uptime_is_positive(): + """Verify uptime_seconds is calculated and is a positive integer.""" + logger = logging.getLogger("test") + config = {} + handler = HandlerHealth(logger, config) + + with ( + patch("src.handlers.handler_health.writer_kafka.STATE", {"logger": logger, "producer": MagicMock()}), + patch( + "src.handlers.handler_health.writer_eventbridge.STATE", + {"logger": logger, "client": MagicMock(), "event_bus_arn": "arn"}, + ), + patch("src.handlers.handler_health.writer_postgres.logger", logger), + ): + response = handler.get_health() + + body = json.loads(response["body"]) + assert "uptime_seconds" in body + assert isinstance(body["uptime_seconds"], int) + assert body["uptime_seconds"] >= 0 + + +## Integration test with event_gate_module +def test_health_endpoint_integration(event_gate_module, make_event): + """Test /health endpoint through lambda_handler.""" + event = make_event("/health") + resp = event_gate_module.lambda_handler(event) + + # Should return 200 since writers are mocked as initialized in conftest + assert resp["statusCode"] == 200 + body = json.loads(resp["body"]) + assert body["status"] == "ok" + assert "uptime_seconds" in body