Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions conf/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/event_gate_lambda.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand All @@ -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}":
Expand Down
101 changes: 101 additions & 0 deletions src/handlers/handler_health.py
Original file line number Diff line number Diff line change
@@ -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}),
}
158 changes: 158 additions & 0 deletions tests/handlers/test_handler_health.py
Original file line number Diff line number Diff line change
@@ -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
Loading