Refine health checks and add polling animation

This commit is contained in:
2025-11-27 11:43:35 +01:00
parent c841e27c30
commit 25d80f5723
18 changed files with 499 additions and 219 deletions

View File

@@ -1,4 +1,4 @@
"""Version 1 API router aggregator."""
"""Version 1 API router aggregator for features."""
from fastapi import APIRouter

View File

@@ -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)

View File

@@ -1 +0,0 @@
"""Health check feature."""

View File

@@ -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)

View File

@@ -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.")

View File

@@ -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

View File

@@ -0,0 +1 @@
"""Domain operation modules."""

View File

@@ -0,0 +1 @@
"""Health feature package."""

View 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

View 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]

View 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})

View File

@@ -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
View File

@@ -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]]