Skip to content

Commit b5968d1

Browse files
committed
Add health check endpoints
1 parent 541e33f commit b5968d1

File tree

5 files changed

+112
-7
lines changed

5 files changed

+112
-7
lines changed

src/app/api/v1/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from fastapi import APIRouter
22

3+
from .health import router as health_router
34
from .login import router as login_router
45
from .logout import router as logout_router
56
from .posts import router as posts_router
@@ -9,6 +10,7 @@
910
from .users import router as users_router
1011

1112
router = APIRouter(prefix="/v1")
13+
router.include_router(health_router)
1214
router.include_router(login_router)
1315
router.include_router(logout_router)
1416
router.include_router(users_router)

src/app/api/v1/health.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import logging
2+
from datetime import UTC, datetime
3+
from typing import Annotated
4+
5+
from fastapi import APIRouter, Depends, status
6+
from fastapi.responses import JSONResponse
7+
from redis.asyncio import Redis
8+
from sqlalchemy.ext.asyncio import AsyncSession
9+
10+
from ...core.config import settings
11+
from ...core.db.database import async_get_db
12+
from ...core.health import check_database_health, check_redis_health
13+
from ...core.schemas import HealthCheck, ReadyCheck
14+
from ...core.utils.cache import async_get_redis
15+
16+
router = APIRouter(tags=["health"])
17+
18+
STATUS_HEALTHY = "healthy"
19+
STATUS_UNHEALTHY = "unhealthy"
20+
21+
LOGGER = logging.getLogger(__name__)
22+
23+
24+
@router.get("/health", response_model=HealthCheck)
25+
async def health():
26+
http_status = status.HTTP_200_OK
27+
response = {
28+
"status": STATUS_HEALTHY,
29+
"environment": settings.ENVIRONMENT.value,
30+
"version": settings.APP_VERSION,
31+
"timestamp": datetime.now(UTC).isoformat(timespec="seconds"),
32+
}
33+
34+
return JSONResponse(status_code=http_status, content=response)
35+
36+
37+
@router.get("/ready", response_model=ReadyCheck)
38+
async def ready(redis: Annotated[Redis, Depends(async_get_redis)], db: Annotated[AsyncSession, Depends(async_get_db)]):
39+
database_status = await check_database_health(db=db)
40+
LOGGER.debug(f"Database health check status: {database_status}")
41+
redis_status = await check_redis_health(redis=redis)
42+
LOGGER.debug(f"Redis health check status: {redis_status}")
43+
44+
# Overall status
45+
overall_status = STATUS_HEALTHY if database_status and redis_status else STATUS_UNHEALTHY
46+
http_status = status.HTTP_200_OK if overall_status == STATUS_HEALTHY else status.HTTP_503_SERVICE_UNAVAILABLE
47+
48+
response = {
49+
"status": overall_status,
50+
"environment": settings.ENVIRONMENT.value,
51+
"version": settings.APP_VERSION,
52+
"app": STATUS_HEALTHY,
53+
"database": STATUS_HEALTHY if database_status else STATUS_UNHEALTHY,
54+
"redis": STATUS_HEALTHY if redis_status else STATUS_UNHEALTHY,
55+
"timestamp": datetime.now(UTC).isoformat(timespec="seconds"),
56+
}
57+
58+
return JSONResponse(status_code=http_status, content=response)

src/app/core/health.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import logging
2+
3+
from redis.asyncio import Redis
4+
from sqlalchemy import text
5+
from sqlalchemy.ext.asyncio import AsyncSession
6+
7+
LOGGER = logging.getLogger(__name__)
8+
9+
10+
async def check_database_health(db: AsyncSession) -> bool:
11+
try:
12+
await db.execute(text("SELECT 1"))
13+
return True
14+
except Exception as e:
15+
LOGGER.exception(f"Database health check failed with error: {e}")
16+
return False
17+
18+
19+
async def check_redis_health(redis: Redis) -> bool:
20+
try:
21+
await redis.ping()
22+
return True
23+
except Exception as e:
24+
LOGGER.exception(f"Redis health check failed with error: {e}")
25+
return False

src/app/core/schemas.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,26 @@
11
import uuid as uuid_pkg
2-
from uuid6 import uuid7
32
from datetime import UTC, datetime
43
from typing import Any
54

65
from pydantic import BaseModel, Field, field_serializer
6+
from uuid6 import uuid7
77

88

99
class HealthCheck(BaseModel):
10-
name: str
10+
status: str
11+
environment: str
12+
version: str
13+
timestamp: str
14+
15+
16+
class ReadyCheck(BaseModel):
17+
status: str
18+
environment: str
1119
version: str
12-
description: str
20+
app: str
21+
database: str
22+
redis: str
23+
timestamp: str
1324

1425

1526
# -------------- mixins --------------

src/app/core/utils/cache.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import functools
22
import json
33
import re
4-
from collections.abc import Callable
4+
from collections.abc import AsyncGenerator, Callable
55
from typing import Any
66

77
from fastapi import Request
@@ -173,13 +173,13 @@ async def _delete_keys_by_pattern(pattern: str) -> None:
173173
"""
174174
if client is None:
175175
return
176-
177-
cursor = 0
176+
177+
cursor = 0
178178
while True:
179179
cursor, keys = await client.scan(cursor, match=pattern, count=100)
180180
if keys:
181181
await client.delete(*keys)
182-
if cursor == 0:
182+
if cursor == 0:
183183
break
184184

185185

@@ -335,3 +335,12 @@ async def inner(request: Request, *args: Any, **kwargs: Any) -> Any:
335335
return inner
336336

337337
return wrapper
338+
339+
340+
async def async_get_redis() -> AsyncGenerator[Redis, None]:
341+
"""Get a Redis client from the pool for each request."""
342+
client = Redis(connection_pool=pool)
343+
try:
344+
yield client
345+
finally:
346+
await client.aclose() # type: ignore

0 commit comments

Comments
 (0)