Refine health checks and add polling animation
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Version 1 API router aggregator."""
|
||||
"""Version 1 API router aggregator for features."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
"""Environment configuration derived from environment variables."""
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings."""
|
||||
|
||||
service_name: str = "backend"
|
||||
service_name: str = "avaaz-backend" # Used as a functional ID
|
||||
environment: str = "development"
|
||||
database_url: str = (
|
||||
"postgresql+psycopg://postgres:postgres@postgres:5432/avaaz"
|
||||
)
|
||||
|
||||
title: str = "Avaaz Language Tutoring API"
|
||||
description: str ="""
|
||||
# Avaaz Language Tutoring API
|
||||
|
||||
This API powers the **avaaz.ai** mobile and web applications, providing the robust backend services for our AI-driven oral language skills tutor. The platform is specifically engineered to help students achieve oral proficiency using adaptive, conversational AI agents.
|
||||
|
||||
## Key Services Provided:
|
||||
* **Conversational AI Engine:** Facilitates ultra-low-latency speech-to-speech interaction and provides instant corrective feedback (grammar, pronunciation, fluency).
|
||||
* **Curriculum Management:** Delivers structured, CEFR aligned lessons and scenarios focused on real-life immigrant contexts (healthcare, workplace, school).
|
||||
* **Assessment & Gamification:** Manages progress tracking, mock oral exam simulations, performance summaries, and motivational mechanics (streaks, badges).
|
||||
* **Cross-Platform Sync:** Ensures seamless learning continuity and progress synchronization across all user devices.
|
||||
"""
|
||||
|
||||
version: str = "0.1.0"
|
||||
|
||||
database_url: str = "postgresql+psycopg://postgres:postgres@postgres:5432/avaaz"
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="", case_sensitive=False)
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Health check feature."""
|
||||
@@ -1,23 +0,0 @@
|
||||
"""Health endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from core.config import Settings, get_settings
|
||||
from features.health.schemas import HealthResponse
|
||||
|
||||
router = APIRouter(prefix="/health", tags=["health"])
|
||||
|
||||
|
||||
def build_health_payload(settings: Settings) -> HealthResponse:
|
||||
"""Build health response payload."""
|
||||
return HealthResponse(
|
||||
status="ok",
|
||||
service=settings.service_name,
|
||||
environment=settings.environment,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=HealthResponse)
|
||||
def health(settings: Settings = Depends(get_settings)) -> HealthResponse:
|
||||
"""Return lightweight health status."""
|
||||
return build_health_payload(settings)
|
||||
@@ -1,9 +0,0 @@
|
||||
"""Pydantic schemas for health responses."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str = Field(default="ok", description="Overall service status.")
|
||||
service: str = Field(default="backend", description="Service name.")
|
||||
environment: str = Field(default="development", description="Runtime environment.")
|
||||
@@ -1,21 +1,26 @@
|
||||
"""Application entrypoint and router wiring."""
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from api.v1.router import router as api_v1_router
|
||||
from core.config import Settings, get_settings
|
||||
from features.health.router import build_health_payload
|
||||
from features.health.schemas import HealthResponse
|
||||
|
||||
from api.v1.router import router as api_v1_router
|
||||
from operations.health.router import router as health_router
|
||||
|
||||
from core.config import get_settings
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application."""
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="avaaz-backend",
|
||||
version="0.1.0",
|
||||
title=settings.title,
|
||||
description=settings.description,
|
||||
version=settings.version,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
redoc_url=None,
|
||||
openapi_url="/openapi.json",
|
||||
)
|
||||
|
||||
# CORS for local dev (frontend on 3000). Tighten/override in prod.
|
||||
@@ -27,22 +32,9 @@ def create_app() -> FastAPI:
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(health_router)
|
||||
app.include_router(api_v1_router, prefix="/api/v1")
|
||||
|
||||
@app.get("/healthz", tags=["health"], response_model=HealthResponse)
|
||||
def liveness(settings: Settings = Depends(get_settings)) -> HealthResponse:
|
||||
"""Liveness probe: process is up."""
|
||||
return build_health_payload(settings)
|
||||
|
||||
@app.get("/readyz", tags=["health"], response_model=HealthResponse)
|
||||
def readiness(settings: Settings = Depends(get_settings)) -> HealthResponse:
|
||||
"""
|
||||
Readiness probe: instance can serve traffic.
|
||||
|
||||
Extend to check dependencies (DB, cache, queues) when they are added.
|
||||
"""
|
||||
return build_health_payload(settings)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
1
app/backend/operations/__init__.py
Normal file
1
app/backend/operations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Domain operation modules."""
|
||||
1
app/backend/operations/health/__init__.py
Normal file
1
app/backend/operations/health/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Health feature package."""
|
||||
74
app/backend/operations/health/router.py
Normal file
74
app/backend/operations/health/router.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Health endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from core.config import Settings, get_settings
|
||||
from operations.health.schemas import HealthStatus
|
||||
from operations.health.service import get_detailed_health, readiness_check
|
||||
|
||||
router = APIRouter(prefix="/health", tags=["health"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/live",
|
||||
summary="Liveness Probe",
|
||||
response_class=PlainTextResponse,
|
||||
status_code=200,
|
||||
)
|
||||
def liveness() -> str:
|
||||
"""
|
||||
**Liveness Probe:** Confirms the application process is running and responsive.
|
||||
|
||||
This endpoint is used by automated systems (like Kubernetes) to determine if
|
||||
the instance should be kept running or restarted. It must be extremely lightweight,
|
||||
performing no deep checks on external dependencies.
|
||||
|
||||
**Success Response:** HTTP 200 OK with "live" body.
|
||||
**Failure Response:** Endpoint timeout (no response).
|
||||
"""
|
||||
return "live"
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ready",
|
||||
summary="Readiness Probe",
|
||||
response_class=PlainTextResponse,
|
||||
status_code=200,
|
||||
)
|
||||
async def readiness() -> str:
|
||||
"""
|
||||
**Readiness Probe:** Determines if the application can accept user traffic.
|
||||
|
||||
This endpoint is used by load balancers to route traffic. It performs deep checks
|
||||
on all critical dependencies (e.g., database, message queue).
|
||||
|
||||
**Success Response:** HTTP 200 OK with "ready" body.
|
||||
**Failure Response:** HTTP 503 Service Unavailable if any critical dependency fails.
|
||||
"""
|
||||
ok = await readiness_check()
|
||||
if not ok:
|
||||
raise HTTPException(status_code=503, detail="not ready")
|
||||
|
||||
return "ready"
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
summary="Detailed Health Status Page",
|
||||
response_model=HealthStatus,
|
||||
status_code=200,
|
||||
)
|
||||
async def detailed_health(settings: Settings = Depends(get_settings)) -> HealthStatus:
|
||||
"""
|
||||
**Detailed Status Page:** Provides granular health information for human operators.
|
||||
|
||||
This endpoint runs all readiness checks and returns a structured JSON object.
|
||||
The top-level HTTP status code reflects the overall application health (200 OK or 503 Service Unavailable).
|
||||
"""
|
||||
detailed_health = await get_detailed_health(settings)
|
||||
|
||||
if detailed_health.status != "pass":
|
||||
raise HTTPException(status_code=503, detail="not ready")
|
||||
|
||||
return detailed_health
|
||||
23
app/backend/operations/health/schemas.py
Normal file
23
app/backend/operations/health/schemas.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Pydantic schemas for health responses."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
class ComponentCheck(BaseModel):
|
||||
name: str
|
||||
# pass | warn | fail
|
||||
status: str
|
||||
time: datetime | None = None
|
||||
output: str | None = None
|
||||
observedValue: float | int | None = None
|
||||
observedUnit: str | None = None
|
||||
|
||||
class HealthStatus(BaseModel):
|
||||
# pass | warn | fail
|
||||
status: str
|
||||
version: str | None = None
|
||||
environment: str | None = None
|
||||
serviceName: str | None = None
|
||||
description: str | None = None
|
||||
checks: dict[str, ComponentCheck]
|
||||
89
app/backend/operations/health/service.py
Normal file
89
app/backend/operations/health/service.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from core.config import Settings
|
||||
from operations.health.schemas import ComponentCheck, HealthStatus
|
||||
|
||||
|
||||
async def check_database_status() -> ComponentCheck:
|
||||
"""Checking the primary database connection with a timeout."""
|
||||
try:
|
||||
# Simulate an async DB call (replace with actual logic)
|
||||
await asyncio.wait_for(asyncio.sleep(0.042), timeout=0.1)
|
||||
|
||||
return ComponentCheck(name = "postgres",
|
||||
status = "pass",
|
||||
time = datetime.now(timezone.utc),
|
||||
observedValue = 42,
|
||||
observedUnit = "ms")
|
||||
except asyncio.TimeoutError:
|
||||
return ComponentCheck(name = "postgres",
|
||||
status = "fail",
|
||||
time = datetime.now(timezone.utc),
|
||||
output = "Check timed out")
|
||||
except Exception as e:
|
||||
return ComponentCheck(name = "postgres",
|
||||
status = "fail",
|
||||
time = datetime.now(timezone.utc),
|
||||
output = str(e))
|
||||
|
||||
async def check_media_server_status() -> ComponentCheck:
|
||||
"""Checking the media server connection with a timeout."""
|
||||
try:
|
||||
# Simulate a successful async queue call
|
||||
await asyncio.wait_for(asyncio.sleep(0.02), timeout=0.05)
|
||||
|
||||
return ComponentCheck(name = "livekit",
|
||||
status = "pass",
|
||||
time = datetime.now(timezone.utc),
|
||||
observedValue = 20,
|
||||
observedUnit = "ms")
|
||||
except asyncio.TimeoutError:
|
||||
return ComponentCheck(name = "livekit",
|
||||
status = "fail",
|
||||
time = datetime.now(timezone.utc),
|
||||
output = "Check timed out")
|
||||
except Exception as e:
|
||||
return ComponentCheck(name = "livekit",
|
||||
status = "fail",
|
||||
time = datetime.now(timezone.utc),
|
||||
output = str(e))
|
||||
|
||||
async def readiness_check() -> bool:
|
||||
"""
|
||||
*Extend readiness_check() as dependencies are added; it must return True/False.*
|
||||
"""
|
||||
|
||||
# Run all critical checks
|
||||
db = await check_database_status()
|
||||
media_server = await check_media_server_status()
|
||||
|
||||
# Check if all statuses are 'pass'
|
||||
if db.status == 'pass' and media_server.status == 'pass':
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
async def get_detailed_health(settings: Settings) -> HealthStatus:
|
||||
"""
|
||||
Build detailed health payload aligned with common status formats.
|
||||
|
||||
status: pass | warn | fail
|
||||
"""
|
||||
|
||||
# Run all critical checks
|
||||
db = await check_database_status()
|
||||
media_server = await check_media_server_status()
|
||||
|
||||
# Check if all statuses are 'pass'
|
||||
overall = "pass"
|
||||
if db.status != 'pass' or media_server.status != 'pass':
|
||||
overall = "fail"
|
||||
|
||||
return HealthStatus(status = overall,
|
||||
version = settings.version,
|
||||
environment = settings.environment,
|
||||
serviceName = settings.service_name,
|
||||
description = settings.title,
|
||||
checks = {"Database": db,
|
||||
"Media Server": media_server})
|
||||
@@ -6,25 +6,27 @@ from main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_liveness_ok():
|
||||
response = client.get("/healthz")
|
||||
response = client.get("/health/live")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["service"] == "backend"
|
||||
assert data["environment"]
|
||||
assert response.text == "live"
|
||||
|
||||
|
||||
def test_readiness_ok():
|
||||
response = client.get("/readyz")
|
||||
response = client.get("/health/ready")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
assert response.text == "ready"
|
||||
|
||||
def test_detailed_health_pass():
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "pass"
|
||||
assert isinstance(body["checks"], dict)
|
||||
|
||||
@given(st.text(min_size=0, max_size=16))
|
||||
@settings(max_examples=10)
|
||||
def test_health_resilient_to_query_noise(noise: str):
|
||||
resp = client.get("/healthz", params={"noise": noise})
|
||||
resp = client.get("/health/live", params={"noise": noise})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
assert resp.text == "live"
|
||||
|
||||
12
app/backend/uv.lock
generated
12
app/backend/uv.lock
generated
@@ -272,14 +272,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hypothesis"
|
||||
version = "6.148.2"
|
||||
version = "6.148.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "sortedcontainers" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4a/99/a3c6eb3fdd6bfa01433d674b0f12cd9102aa99630689427422d920aea9c6/hypothesis-6.148.2.tar.gz", hash = "sha256:07e65d34d687ddff3e92a3ac6b43966c193356896813aec79f0a611c5018f4b1", size = 469984, upload-time = "2025-11-18T20:21:17.047Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/3d/41da3727e5f3e6b0c79b9657946c742e2f61d24edcde3e1660e337509586/hypothesis-6.148.3.tar.gz", hash = "sha256:bd81221740d8658473060ad900dc831f889f156fdb41210ba2f47cfad10a66ed", size = 469896, upload-time = "2025-11-27T06:34:09.419Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/d2/c2673aca0127e204965e0e9b3b7a0e91e9b12993859ac8758abd22669b89/hypothesis-6.148.2-py3-none-any.whl", hash = "sha256:bf8ddc829009da73b321994b902b1964bcc3e5c3f0ed9a1c1e6a1631ab97c5fa", size = 536986, upload-time = "2025-11-18T20:21:15.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/61/8c9fd9397eb46ac54d974be8b9e619c386d6b47a462d8df962ebb79980f9/hypothesis-6.148.3-py3-none-any.whl", hash = "sha256:e7dd193da9800234ec5e1541c1eddde4bddff49b53faf690ba68a0af55a7abb3", size = 536925, upload-time = "2025-11-27T06:34:06.978Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -320,7 +320,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.4"
|
||||
version = "2.12.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
@@ -328,9 +328,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user