diff --git a/APISpecification.yaml b/APISpecification.yaml index b763acb..859e21f 100644 --- a/APISpecification.yaml +++ b/APISpecification.yaml @@ -11,6 +11,35 @@ tags: - name: api description: Ensembl Web Metadata API paths: + /grpc_status: + get: + summary: Check gRPC Server or Service Health + description: | + - If `service_name` is not provided, it checks the overall gRPC server health. + - If `service_name` is provided, it checks that specific service (e.g. `EnsemblMetadata`). + parameters: + - name: service_name + in: query + required: false + schema: + type: string + description: The specific gRPC service name to check (leave empty for global health check). + responses: + 200: + description: gRPC service is running and healthy. + content: + application/json: + schema: + type: object + properties: + status: + type: integer + example: 1 + code: + type: string + example: "OK" + 503: + '$ref': '#/components/responses/503ServiceUnavailable' /api/metadata/genome/{genome_id_or_slug}/explain: get: description: Satisfies client's need to disambiguate a string that is part of url pathname @@ -1006,6 +1035,19 @@ components: schema: type: string example: '{"status_code": 404, "details": "Not Found"}' + 503ServiceUnavailable: + description: gRPC service is unavailable. + content: + application/json: + schema: + type: object + properties: + status: + type: integer + example: 0 + code: + type: string + example: "UNAVAILABLE" 500InternalServerError: description: Internal server error content: diff --git a/app/api/resources/grpc_client.py b/app/api/resources/grpc_client.py index 83c7eae..533206b 100644 --- a/app/api/resources/grpc_client.py +++ b/app/api/resources/grpc_client.py @@ -15,24 +15,54 @@ import logging import grpc from google.protobuf.json_format import MessageToDict +from grpc_health.v1 import health_pb2_grpc, health_pb2 from yagrc import reflector as yagrc_reflector +logger = logging.getLogger(__name__) class GRPCClient: def __init__(self, host: str, port: int): + self.grpc_server_address = f"{host}:{port}" # Create Channel - channel = grpc.insecure_channel( - "{}:{}".format(host, port), + self.channel = grpc.insecure_channel( + self.grpc_server_address, options=(("grpc.enable_http_proxy", 0),), ) + self.health_stub = health_pb2_grpc.HealthStub(self.channel) + + self.stub = None # Default stub (will be set only if gRPC is available) self.reflector = yagrc_reflector.GrpcReflectionClient() - self.reflector.load_protocols( - channel, symbols=["ensembl_metadata.EnsemblMetadata"] - ) - stub_class = self.reflector.service_stub_class( - "ensembl_metadata.EnsemblMetadata" - ) - self.stub = stub_class(channel) + + try: + # Attempt to load gRPC reflection protocols + self.reflector.load_protocols( + self.channel, symbols=["ensembl_metadata.EnsemblMetadata"] + ) + stub_class = self.reflector.service_stub_class("ensembl_metadata.EnsemblMetadata") + self.stub = stub_class(self.channel) # Assign stub only if reflection succeeds + logger.info("gRPC reflection loaded successfully.") + except grpc.RpcError as e: + logger.warning(f"Failed to connect to gRPC service at {self.grpc_server_address}. " + f"Reflection unavailable. Error: {e.details()}") + + + def get_grpc_status(self, service_name: str = ""): + """ + Checks the health of the gRPC server or a specific service. + + Parameters: + service_name (str): Name of the gRPC service to check. Empty string `""` checks the entire server. + + Returns: + dict: {"status": "SERVING" or "NOT_SERVING", "code": ""} + """ + request = health_pb2.HealthCheckRequest(service=service_name) + try: + response = self.health_stub.Check(request) + return {"status": response.status, "code": "OK"} + except grpc.RpcError as e: + return {"status": 0, "code": str(e.code().name)} # Handle errors gracefully + def get_statistics(self, genome_uuid: str): """Returns gRPC message containing statistics for given genome_uuid. diff --git a/app/api/resources/metadata.py b/app/api/resources/metadata.py index b34e1bb..bfed59a 100644 --- a/app/api/resources/metadata.py +++ b/app/api/resources/metadata.py @@ -16,9 +16,10 @@ """ import json import logging -from typing import Annotated +from typing import Annotated, Optional from fastapi import APIRouter, Request, responses, Query +from fastapi.responses import JSONResponse from pydantic import ValidationError from api.error_response import response_error_handler @@ -45,6 +46,22 @@ logging.info("Connecting to gRPC server on " + GRPC_HOST + ":" + str(GRPC_PORT)) grpc_client = GRPCClient(GRPC_HOST, GRPC_PORT) +@router.get("/grpc_status", summary="Check gRPC Server or Service Health") +async def service_health_check(service_name: Optional[str] = None): + """ + HTTP endpoint to check the health status of the entire gRPC server or a specific service. + + - If `service_name` is not provided, it checks the global health. + - If `service_name` is provided, it checks that specific service (e.g. `EnsemblMetadata`). + """ + # return grpc_client.get_grpc_status(service_name or "") + result = grpc_client.get_grpc_status(service_name or "") + + if result["code"] == "OK": + return result + + # if gRPC is down, return JSON response with HTTP 503 + return JSONResponse(content=result, status_code=503) @router.get("/genome/{genome_uuid}/stats", name="statistics") async def get_metadata_statistics(request: Request, genome_uuid: str): diff --git a/requirements.txt b/requirements.txt index 9b47f6a..d79e47e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ click==8.1.7 exceptiongroup==1.2.0 fastapi==0.103.1 grpcio==1.60.0 +grpcio-health-checking==1.60.0 grpcio-tools==1.60.0 h11==0.14.0 idna==3.6