From f982260f294e79b71e6b1dc220ee0b43e12f9f32 Mon Sep 17 00:00:00 2001 From: sarangs1621 Date: Sat, 13 Jun 2026 03:06:40 +0530 Subject: [PATCH] Add /health/ready dependency check; fix oversized SECRET_KEY placeholder /health stays a static liveness probe; /health/ready now checks Postgres (SELECT 1) and Redis (PING) and returns 503 with per-dependency status if either is unreachable, so platforms that gate routing/restarts on a health endpoint can detect a broken DB/Redis connection. The .env.example SECRET_KEY placeholder was 33 chars, which happens to pass the >=32-char startup validation -- a copy-paste deploy that forgot to regenerate it would silently run with a public, known JWT secret instead of failing fast. Shortened it to a value that fails validation until replaced. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 6 +++-- app/main.py | 24 +++++++++++++++++ tests/test_security_headers.py | 47 ++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index c10bc05..d63e7b8 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,10 @@ API_V1_PREFIX=/api/v1 BACKEND_CORS_ORIGINS=["http://localhost:3000"] # --- Security / JWT --- -# Generate with: python -c "import secrets; print(secrets.token_urlsafe(64))" -SECRET_KEY=change-me-to-a-long-random-secret +# Required, >=32 chars outside ENVIRONMENT=testing. This placeholder is +# intentionally short so the app refuses to start until you generate a +# real secret with: python -c "import secrets; print(secrets.token_urlsafe(64))" +SECRET_KEY=change-me JWT_ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=30 REFRESH_TOKEN_EXPIRE_DAYS=7 diff --git a/app/main.py b/app/main.py index 9d4d882..76ce0b0 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,9 @@ from fastapi import FastAPI, Request, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from redis.exceptions import RedisError +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError from app.api.v1.router import api_router from app.core.config import settings @@ -13,7 +16,9 @@ ValidationError, ) from app.core.rate_limit import RateLimitMiddleware +from app.core.redis import get_redis_client from app.core.security_headers import SecurityHeadersMiddleware +from app.db.session import AsyncSessionLocal def register_exception_handlers(app: FastAPI) -> None: @@ -71,6 +76,25 @@ def create_app() -> FastAPI: async def health_check() -> dict[str, str]: return {"status": "ok"} + @app.get("/health/ready", tags=["health"]) + async def readiness_check() -> JSONResponse: + checks = {"database": "ok", "redis": "ok"} + + try: + async with AsyncSessionLocal() as session: + await session.execute(text("SELECT 1")) + except SQLAlchemyError: + checks["database"] = "error" + + try: + await get_redis_client().ping() + except RedisError: + checks["redis"] = "error" + + is_ready = all(value == "ok" for value in checks.values()) + status_code = status.HTTP_200_OK if is_ready else status.HTTP_503_SERVICE_UNAVAILABLE + return JSONResponse(status_code=status_code, content={"status": "ok" if is_ready else "error", **checks}) + return app diff --git a/tests/test_security_headers.py b/tests/test_security_headers.py index f712428..f8d8dcd 100644 --- a/tests/test_security_headers.py +++ b/tests/test_security_headers.py @@ -1,5 +1,7 @@ import pytest from httpx import ASGITransport, AsyncClient +from redis.exceptions import RedisError +from sqlalchemy.exc import SQLAlchemyError from app.core.config import settings from app.main import app @@ -41,6 +43,51 @@ async def test_hsts_present_only_over_https() -> None: ) +async def test_readiness_check_reports_ok_when_dependencies_healthy(client: AsyncClient) -> None: + response = await client.get("/health/ready") + + assert response.status_code == 200 + assert response.json() == {"status": "ok", "database": "ok", "redis": "ok"} + + +async def test_readiness_check_returns_503_when_database_unreachable( + client: AsyncClient, monkeypatch: pytest.MonkeyPatch +) -> None: + class _BrokenSessionFactory: + def __call__(self) -> "_BrokenSessionFactory": + return self + + async def __aenter__(self) -> "_BrokenSessionFactory": + raise SQLAlchemyError("connection refused") + + async def __aexit__(self, *exc_info: object) -> bool: + return False + + monkeypatch.setattr("app.main.AsyncSessionLocal", _BrokenSessionFactory()) + + response = await client.get("/health/ready") + + assert response.status_code == 503 + body = response.json() + assert body == {"status": "error", "database": "error", "redis": "ok"} + + +async def test_readiness_check_returns_503_when_redis_unreachable( + client: AsyncClient, monkeypatch: pytest.MonkeyPatch +) -> None: + class _BrokenRedisClient: + async def ping(self) -> bool: + raise RedisError("connection refused") + + monkeypatch.setattr("app.main.get_redis_client", lambda: _BrokenRedisClient()) + + response = await client.get("/health/ready") + + assert response.status_code == 503 + body = response.json() + assert body == {"status": "error", "database": "ok", "redis": "error"} + + async def test_oversized_request_body_returns_413(client: AsyncClient) -> None: response = await client.post( "/api/v1/auth/register",