Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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"
Comment on lines +83 to +92

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Add timeouts to dependency health checks.

Neither the database nor Redis connectivity check has an explicit timeout. If either dependency hangs (network partition, slow query, etc.) rather than immediately refusing the connection, the readiness probe will hang indefinitely. Orchestration systems (Kubernetes, Docker Swarm, etc.) expect health checks to complete within a configured timeout window; a hanging probe can prevent proper failover, scaling, and traffic routing.

Consider wrapping each check in asyncio.wait_for() with a reasonable timeout (e.g., 2-5 seconds).

⏱️ Proposed fix to add timeouts
+import asyncio
+
 from fastapi import FastAPI, Request, status
 from fastapi.middleware.cors import CORSMiddleware
 ...

     `@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"))
+            async with asyncio.timeout(5.0):
+                async with AsyncSessionLocal() as session:
+                    await session.execute(text("SELECT 1"))
-        except SQLAlchemyError:
+        except (SQLAlchemyError, TimeoutError):
             checks["database"] = "error"

         try:
-            await get_redis_client().ping()
-        except RedisError:
+            async with asyncio.timeout(5.0):
+                await get_redis_client().ping()
+        except (RedisError, TimeoutError):
             checks["redis"] = "error"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/main.py` around lines 83 - 92, The health checks for AsyncSessionLocal
(database) and get_redis_client().ping() (redis) can hang; wrap each awaited
operation in asyncio.wait_for with a short timeout (e.g., 2–5s) and handle
asyncio.TimeoutError to mark checks["database"] or checks["redis"] = "error";
keep existing exception handlers for SQLAlchemyError and RedisError but ensure
timeouts are caught separately to avoid blocking readiness probes.


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


Expand Down
47 changes: 47 additions & 0 deletions tests/test_security_headers.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
Expand Down
Loading