From b5968d1bad129f884f933f6e0d0c5b85670fd9c5 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Tue, 21 Oct 2025 21:58:55 +0800 Subject: [PATCH 1/3] Add health check endpoints --- src/app/api/v1/__init__.py | 2 ++ src/app/api/v1/health.py | 58 +++++++++++++++++++++++++++++++++++++ src/app/core/health.py | 25 ++++++++++++++++ src/app/core/schemas.py | 17 +++++++++-- src/app/core/utils/cache.py | 17 ++++++++--- 5 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 src/app/api/v1/health.py create mode 100644 src/app/core/health.py diff --git a/src/app/api/v1/__init__.py b/src/app/api/v1/__init__.py index 365e7569..7575848f 100644 --- a/src/app/api/v1/__init__.py +++ b/src/app/api/v1/__init__.py @@ -1,5 +1,6 @@ from fastapi import APIRouter +from .health import router as health_router from .login import router as login_router from .logout import router as logout_router from .posts import router as posts_router @@ -9,6 +10,7 @@ from .users import router as users_router router = APIRouter(prefix="/v1") +router.include_router(health_router) router.include_router(login_router) router.include_router(logout_router) router.include_router(users_router) diff --git a/src/app/api/v1/health.py b/src/app/api/v1/health.py new file mode 100644 index 00000000..163172dd --- /dev/null +++ b/src/app/api/v1/health.py @@ -0,0 +1,58 @@ +import logging +from datetime import UTC, datetime +from typing import Annotated + +from fastapi import APIRouter, Depends, status +from fastapi.responses import JSONResponse +from redis.asyncio import Redis +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.config import settings +from ...core.db.database import async_get_db +from ...core.health import check_database_health, check_redis_health +from ...core.schemas import HealthCheck, ReadyCheck +from ...core.utils.cache import async_get_redis + +router = APIRouter(tags=["health"]) + +STATUS_HEALTHY = "healthy" +STATUS_UNHEALTHY = "unhealthy" + +LOGGER = logging.getLogger(__name__) + + +@router.get("/health", response_model=HealthCheck) +async def health(): + http_status = status.HTTP_200_OK + response = { + "status": STATUS_HEALTHY, + "environment": settings.ENVIRONMENT.value, + "version": settings.APP_VERSION, + "timestamp": datetime.now(UTC).isoformat(timespec="seconds"), + } + + return JSONResponse(status_code=http_status, content=response) + + +@router.get("/ready", response_model=ReadyCheck) +async def ready(redis: Annotated[Redis, Depends(async_get_redis)], db: Annotated[AsyncSession, Depends(async_get_db)]): + database_status = await check_database_health(db=db) + LOGGER.debug(f"Database health check status: {database_status}") + redis_status = await check_redis_health(redis=redis) + LOGGER.debug(f"Redis health check status: {redis_status}") + + # Overall status + overall_status = STATUS_HEALTHY if database_status and redis_status else STATUS_UNHEALTHY + http_status = status.HTTP_200_OK if overall_status == STATUS_HEALTHY else status.HTTP_503_SERVICE_UNAVAILABLE + + response = { + "status": overall_status, + "environment": settings.ENVIRONMENT.value, + "version": settings.APP_VERSION, + "app": STATUS_HEALTHY, + "database": STATUS_HEALTHY if database_status else STATUS_UNHEALTHY, + "redis": STATUS_HEALTHY if redis_status else STATUS_UNHEALTHY, + "timestamp": datetime.now(UTC).isoformat(timespec="seconds"), + } + + return JSONResponse(status_code=http_status, content=response) diff --git a/src/app/core/health.py b/src/app/core/health.py new file mode 100644 index 00000000..60e2e050 --- /dev/null +++ b/src/app/core/health.py @@ -0,0 +1,25 @@ +import logging + +from redis.asyncio import Redis +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +LOGGER = logging.getLogger(__name__) + + +async def check_database_health(db: AsyncSession) -> bool: + try: + await db.execute(text("SELECT 1")) + return True + except Exception as e: + LOGGER.exception(f"Database health check failed with error: {e}") + return False + + +async def check_redis_health(redis: Redis) -> bool: + try: + await redis.ping() + return True + except Exception as e: + LOGGER.exception(f"Redis health check failed with error: {e}") + return False diff --git a/src/app/core/schemas.py b/src/app/core/schemas.py index 28f7ed0f..9566aa52 100644 --- a/src/app/core/schemas.py +++ b/src/app/core/schemas.py @@ -1,15 +1,26 @@ import uuid as uuid_pkg -from uuid6 import uuid7 from datetime import UTC, datetime from typing import Any from pydantic import BaseModel, Field, field_serializer +from uuid6 import uuid7 class HealthCheck(BaseModel): - name: str + status: str + environment: str + version: str + timestamp: str + + +class ReadyCheck(BaseModel): + status: str + environment: str version: str - description: str + app: str + database: str + redis: str + timestamp: str # -------------- mixins -------------- diff --git a/src/app/core/utils/cache.py b/src/app/core/utils/cache.py index 0d0167fc..ca082466 100644 --- a/src/app/core/utils/cache.py +++ b/src/app/core/utils/cache.py @@ -1,7 +1,7 @@ import functools import json import re -from collections.abc import Callable +from collections.abc import AsyncGenerator, Callable from typing import Any from fastapi import Request @@ -173,13 +173,13 @@ async def _delete_keys_by_pattern(pattern: str) -> None: """ if client is None: return - - cursor = 0 + + cursor = 0 while True: cursor, keys = await client.scan(cursor, match=pattern, count=100) if keys: await client.delete(*keys) - if cursor == 0: + if cursor == 0: break @@ -335,3 +335,12 @@ async def inner(request: Request, *args: Any, **kwargs: Any) -> Any: return inner return wrapper + + +async def async_get_redis() -> AsyncGenerator[Redis, None]: + """Get a Redis client from the pool for each request.""" + client = Redis(connection_pool=pool) + try: + yield client + finally: + await client.aclose() # type: ignore From 931a5aa8581c112b7e2fa4a0b4dd0388cb8e65ee Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Tue, 21 Oct 2025 22:53:39 +0800 Subject: [PATCH 2/3] Update documentation with health and ready checks endpoints --- docs/getting-started/first-run.md | 24 ++++++++- docs/getting-started/index.md | 10 +++- docs/getting-started/installation.md | 7 +-- .../configuration/environment-variables.md | 15 ------ docs/user-guide/production.md | 50 +++---------------- 5 files changed, 41 insertions(+), 65 deletions(-) diff --git a/docs/getting-started/first-run.md b/docs/getting-started/first-run.md index 21764866..e12fceb7 100644 --- a/docs/getting-started/first-run.md +++ b/docs/getting-started/first-run.md @@ -38,8 +38,28 @@ curl http://localhost:8000/api/v1/health Expected response: ```json { - "status": "healthy", - "timestamp": "2024-01-01T12:00:00Z" + "status":"healthy", + "environment":"local", + "version":"0.1.0", + "timestamp":"2025-10-21T14:40:14+00:00" +} +``` + +**Ready Check:** +```bash +curl http://localhost:8000/api/v1/ready +``` + +Expected response: +```json +{ + "status":"healthy", + "environment":"local", + "version":"0.1.0", + "app":"healthy", + "database":"healthy", + "redis":"healthy", + "timestamp":"2025-10-21T14:40:47+00:00" } ``` diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 96d54252..b5356ee9 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -104,6 +104,7 @@ Visit these URLs to confirm everything is working: - **API Documentation**: [http://localhost:8000/docs](http://localhost:8000/docs) - **Alternative Docs**: [http://localhost:8000/redoc](http://localhost:8000/redoc) - **Health Check**: [http://localhost:8000/api/v1/health](http://localhost:8000/api/v1/health) +- **Ready Check**: [http://localhost:8000/api/v1/ready](http://localhost:8000/api/v1/ready) ## You're Ready! @@ -126,7 +127,12 @@ Try these quick tests to see your API in action: curl http://localhost:8000/api/v1/health ``` -### 2. Create a User +### 2. Ready Check +```bash +curl http://localhost:8000/api/v1/ready +``` + +### 3. Create a User ```bash curl -X POST "http://localhost:8000/api/v1/users" \ -H "Content-Type: application/json" \ @@ -138,7 +144,7 @@ curl -X POST "http://localhost:8000/api/v1/users" \ }' ``` -### 3. Login +### 4. Login ```bash curl -X POST "http://localhost:8000/api/v1/login" \ -H "Content-Type: application/x-www-form-urlencoded" \ diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 25c019d4..0cdd66af 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -290,9 +290,10 @@ After installation, verify everything works: 1. **API Documentation**: http://localhost:8000/docs 2. **Health Check**: http://localhost:8000/api/v1/health -3. **Database Connection**: Check logs for successful connection -4. **Redis Connection**: Test caching functionality -5. **Background Tasks**: Submit a test job +3. **Ready Check**: http://localhost:8000/api/v1/ready +4. **Database Connection**: Check logs for successful connection +5. **Redis Connection**: Test caching functionality +6. **Background Tasks**: Submit a test job ## Troubleshooting diff --git a/docs/user-guide/configuration/environment-variables.md b/docs/user-guide/configuration/environment-variables.md index e1714d41..199a5297 100644 --- a/docs/user-guide/configuration/environment-variables.md +++ b/docs/user-guide/configuration/environment-variables.md @@ -526,21 +526,6 @@ if settings.ENABLE_ADVANCED_CACHING: pass ``` -### Health Checks - -Configure health check endpoints: - -```python -@app.get("/health") -async def health_check(): - return { - "status": "healthy", - "database": await check_database_health(), - "redis": await check_redis_health(), - "version": settings.APP_VERSION - } -``` - ## Configuration Validation ### Environment Validation diff --git a/docs/user-guide/production.md b/docs/user-guide/production.md index 53fecbf7..3dfdd8be 100644 --- a/docs/user-guide/production.md +++ b/docs/user-guide/production.md @@ -318,6 +318,13 @@ http { access_log off; } + # Ready check endpoint (no rate limiting) + location /ready { + proxy_pass http://fastapi_backend; + proxy_set_header Host $host; + access_log off; + } + # Static files (if any) location /static/ { alias /code/static/; @@ -554,49 +561,6 @@ DEFAULT_RATE_LIMIT_LIMIT = 100 # requests per period DEFAULT_RATE_LIMIT_PERIOD = 3600 # 1 hour ``` -### Health Checks - -#### Application Health Check - -```python -# src/app/api/v1/health.py -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from ...core.db.database import async_get_db -from ...core.utils.cache import redis_client - -router = APIRouter() - -@router.get("/health") -async def health_check(): - return {"status": "healthy", "timestamp": datetime.utcnow()} - -@router.get("/health/detailed") -async def detailed_health_check(db: AsyncSession = Depends(async_get_db)): - health_status = {"status": "healthy", "services": {}} - - # Check database - try: - await db.execute("SELECT 1") - health_status["services"]["database"] = "healthy" - except Exception: - health_status["services"]["database"] = "unhealthy" - health_status["status"] = "unhealthy" - - # Check Redis - try: - await redis_client.ping() - health_status["services"]["redis"] = "healthy" - except Exception: - health_status["services"]["redis"] = "unhealthy" - health_status["status"] = "unhealthy" - - if health_status["status"] == "unhealthy": - raise HTTPException(status_code=503, detail=health_status) - - return health_status -``` - ### Deployment Process #### CI/CD Pipeline (GitHub Actions) From 4a7a2f6856ecffa75f9ee9f7db9254c0ceb19727 Mon Sep 17 00:00:00 2001 From: LucasQR <114786479+LucasQR@users.noreply.github.com> Date: Tue, 21 Oct 2025 17:03:43 -0300 Subject: [PATCH 3/3] Removing dev comment --- src/app/api/v1/health.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/api/v1/health.py b/src/app/api/v1/health.py index 163172dd..fd55071e 100644 --- a/src/app/api/v1/health.py +++ b/src/app/api/v1/health.py @@ -41,7 +41,6 @@ async def ready(redis: Annotated[Redis, Depends(async_get_redis)], db: Annotated redis_status = await check_redis_health(redis=redis) LOGGER.debug(f"Redis health check status: {redis_status}") - # Overall status overall_status = STATUS_HEALTHY if database_status and redis_status else STATUS_UNHEALTHY http_status = status.HTTP_200_OK if overall_status == STATUS_HEALTHY else status.HTTP_503_SERVICE_UNAVAILABLE