Skip to content

Commit 219e75c

Browse files
author
luisleo526
committed
Feat: Add health check endpoint for MySQL and Redis connectivity, update Dockerfile for faster package installation, and enhance Redis client support for clusters and standalone instances.
1 parent 212cf9d commit 219e75c

6 files changed

Lines changed: 143 additions & 22 deletions

File tree

‎Dockerfile‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
1818
libpq-dev \
1919
&& rm -rf /var/lib/apt/lists/*
2020

21-
# Copy requirements and install Python dependencies
21+
# Install uv for faster package installation
22+
RUN pip install --no-cache-dir uv
23+
24+
# Copy requirements and install Python dependencies using uv
2225
COPY requirements.txt .
23-
RUN pip install --user --no-warn-script-location -r requirements.txt
26+
RUN uv pip install --system --no-cache -r requirements.txt
2427

2528
# Stage 2: Runtime stage
2629
FROM python:3.11-slim
2730

2831
# Set environment variables
2932
ENV PYTHONDONTWRITEBYTECODE=1 \
3033
PYTHONUNBUFFERED=1 \
31-
PYTHONPATH="/app" \
32-
PATH="/home/appuser/.local/bin:${PATH}"
34+
PYTHONPATH="/app"
3335

3436
# Install runtime dependencies only
3537
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -44,8 +46,9 @@ RUN useradd -m -u 1000 appuser && \
4446

4547
WORKDIR /app
4648

47-
# Copy Python packages from builder
48-
COPY --from=builder --chown=appuser:appuser /root/.local /home/appuser/.local
49+
# Copy Python packages from builder (system site-packages)
50+
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
51+
COPY --from=builder /usr/local/bin /usr/local/bin
4952

5053
# Copy application code
5154
COPY --chown=appuser:appuser . /app
@@ -55,7 +58,7 @@ USER appuser
5558

5659
# Health check
5760
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
58-
CMD curl -f http://localhost:8000/health || exit 1
61+
CMD curl -f http://localhost:8000/healthz || exit 1
5962

6063
# Expose the application port
6164
EXPOSE 8000

‎example.env‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ DB_PASS=admin1234
44
DB_PORT=3306
55
DB_NAME=template_db
66

7+
# Redis Configuration
8+
REDIS_HOST=redis
9+
REDIS_PORT=6379
10+
# Set to 'true' for Redis Cluster (AWS ElastiCache), 'false' for standalone Redis
11+
REDIS_CLUSTER=false
12+
713
AWS_ACCESS_KEY_ID=
814
AWS_SECRET_ACCESS_KEY=
915
AWS_REGION=

‎requirements.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ passlib
99
bcrypt
1010
email-validator
1111
python-ulid[pydantic]
12-
redis[hiredis]
12+
redis[hiredis]>=4.2.0
1313
boto3
1414
alembic
1515
pwdlib[argon2]

‎src/dependencies/basic.py‎

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,74 @@
11
import os
2+
import ssl
3+
from typing import Generator, Union
24

3-
from redis import StrictRedis as Redis
5+
from redis import RedisCluster, StrictRedis
6+
from sqlalchemy.orm import Session
47

58
from src.database.database import SessionLocal
69

710

8-
def get_redis_client() -> Redis:
9-
r = Redis(
10-
host=os.getenv('REDIS_HOST', 'redis'),
11-
port=int(os.getenv('REDIS_PORT', '6379')),
12-
decode_responses=True
13-
)
11+
def get_redis_client(db: int = 0) -> Generator[Union[RedisCluster, StrictRedis], None, None]:
12+
"""
13+
FastAPI dependency for Redis client that supports both Redis Cluster (AWS ElastiCache) and standalone Redis.
14+
15+
Usage:
16+
@app.get("/endpoint")
17+
def my_endpoint(redis: Redis = Depends(get_redis_client)):
18+
redis.set("key", "value")
19+
20+
Args:
21+
db: Database number (only used for standalone Redis, clusters don't support db selection)
22+
23+
Yields:
24+
RedisCluster or StrictRedis instance
25+
"""
26+
ssl_context = ssl.create_default_context()
27+
ssl_context.check_hostname = False
28+
ssl_context.verify_mode = ssl.CERT_NONE
29+
30+
if os.getenv('REDIS_CLUSTER', 'false').lower() == 'true':
31+
# Redis Cluster doesn't support database selection - all data goes to db=0
32+
# This fallback ensures consistent behavior across cluster and standalone modes
33+
r = RedisCluster(
34+
host=os.getenv('REDIS_HOST', 'redis'),
35+
port=int(os.getenv('REDIS_PORT', '6379')),
36+
decode_responses=True,
37+
ssl=True,
38+
ssl_context=ssl_context,
39+
socket_connect_timeout=5,
40+
socket_timeout=5,
41+
skip_full_coverage_check=True
42+
)
43+
else:
44+
# Standalone Redis with database selection support
45+
r = StrictRedis(
46+
host=os.getenv('REDIS_HOST', 'redis'),
47+
port=int(os.getenv('REDIS_PORT', '6379')),
48+
db=db,
49+
decode_responses=True,
50+
socket_connect_timeout=5,
51+
socket_timeout=5
52+
)
53+
1454
try:
1555
yield r
1656
finally:
1757
r.close()
1858

1959

20-
def get_db():
60+
def get_db() -> Generator[Session, None, None]:
61+
"""
62+
FastAPI dependency for database session.
63+
64+
Usage:
65+
@app.get("/endpoint")
66+
def my_endpoint(db: Session = Depends(get_db)):
67+
db.query(User).all()
68+
69+
Yields:
70+
SQLAlchemy Session instance
71+
"""
2172
db = SessionLocal()
2273
try:
2374
yield db

‎src/schemas/basic.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,14 @@ class UploadedFile(BaseModel):
1515
original_file_name: str = Field(..., title="Original File Name")
1616
extension: str = Field(..., title="File Extension")
1717
content_type: str = Field(..., title="Content Type")
18+
19+
20+
class ServiceStatus(BaseModel):
21+
status: str = Field(..., title="Status", description="Service status: healthy or unhealthy")
22+
message: str = Field(default="", title="Message", description="Additional information")
23+
24+
25+
class HealthCheck(BaseModel):
26+
status: str = Field(..., title="Overall Status", description="Overall health status")
27+
mysql: ServiceStatus = Field(..., title="MySQL Status")
28+
redis: ServiceStatus = Field(..., title="Redis Status")

‎src/server.py‎

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
1+
import logging
12
import os
2-
from fastapi import FastAPI
3+
4+
from fastapi import FastAPI, status
35
from fastapi.middleware.cors import CORSMiddleware
4-
from fastapi.responses import HTMLResponse
5-
from starlette.requests import Request
6+
from fastapi.responses import HTMLResponse, JSONResponse
7+
from sqlalchemy import text
68
import sentry_sdk
79
from sentry_sdk.integrations.fastapi import FastApiIntegration
8-
from sentry_sdk.integrations.starlette import StarletteIntegration
910
from sentry_sdk.integrations.logging import LoggingIntegration
1011
from sentry_sdk.integrations.redis import RedisIntegration
1112
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
13+
from sentry_sdk.integrations.starlette import StarletteIntegration
1214
from sentry_sdk.integrations.threading import ThreadingIntegration
13-
import logging
15+
from starlette.requests import Request
1416

17+
from src.database.database import engine
18+
from src.dependencies.basic import get_redis_client
1519
from src.routers.server import router
16-
from src.schemas.basic import TextOnly
20+
from src.schemas.basic import TextOnly, HealthCheck, ServiceStatus
1721
from src.utils.swagger import custom_swagger_ui_html
1822

1923
# Initialize Sentry
@@ -98,6 +102,52 @@ async def root():
98102
return TextOnly(text="Hello World")
99103

100104

105+
@app.get("/healthz", response_model=HealthCheck, tags=["Health"])
106+
async def health_check():
107+
"""
108+
Health check endpoint that verifies MySQL and Redis connectivity.
109+
Returns 200 if all services are healthy, 503 if any service is unhealthy.
110+
"""
111+
mysql_status = ServiceStatus(status="healthy", message="Connected")
112+
redis_status = ServiceStatus(status="healthy", message="Connected")
113+
overall_healthy = True
114+
115+
# Check MySQL connection
116+
try:
117+
with engine.connect() as connection:
118+
connection.execute(text("SELECT 1"))
119+
except Exception as e:
120+
mysql_status = ServiceStatus(status="unhealthy", message=f"Connection failed: {str(e)}")
121+
overall_healthy = False
122+
123+
# Check Redis connection
124+
try:
125+
redis_gen = get_redis_client()
126+
redis_client = next(redis_gen)
127+
redis_client.ping()
128+
try:
129+
next(redis_gen) # Trigger the finally block to close the connection
130+
except StopIteration:
131+
pass
132+
except Exception as e:
133+
redis_status = ServiceStatus(status="unhealthy", message=f"Connection failed: {str(e)}")
134+
overall_healthy = False
135+
136+
health_response = HealthCheck(
137+
status="healthy" if overall_healthy else "unhealthy",
138+
mysql=mysql_status,
139+
redis=redis_status
140+
)
141+
142+
if overall_healthy:
143+
return health_response
144+
else:
145+
return JSONResponse(
146+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
147+
content=health_response.model_dump()
148+
)
149+
150+
101151
@app.get("/elements", include_in_schema=False)
102152
async def api_documentation(request: Request):
103153
return HTMLResponse("""

0 commit comments

Comments
 (0)