Merge pull request #159 from MervinPraison/claude/issue-152-20260624-0952

feat: parallelize legacy /health and migrate consumers to fast probes
This commit is contained in:
Mervin Praison authored and GitHub committed 2026-06-24 23:17:44 +01:00
commit 5e512d28da
5 files changed
+114 -24

No files matched your search

+36 -6
View File
@@ -49,7 +49,7 @@ services:
volumes:
- aiui-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8082/health"]
test: ["CMD", "curl", "-f", "http://localhost:8082/health/live"]
interval: 30s
timeout: 5s
retries: 3
@@ -81,7 +81,7 @@ curl -H "Authorization: Bearer <api-key>" http://localhost:8082/api/config
```
**Exempt paths** (always accessible without auth):
- `/health`, `/api/health` — health checks
- `/health`, `/health/live`, `/health/ready`, `/api/health` — health checks
- `/api/auth/login`, `/login` — login endpoints
- `/api/protocol`, `/api/protocol/negotiate` — protocol discovery
@@ -146,8 +146,36 @@ This affects:
### Health Endpoint
```bash
# Fast liveness probe (<500ms) - use for load balancers and K8s livenessProbe
curl http://localhost:8082/health/live
# Returns: {"status": "ok", "timestamp": "2024-..."}
# Readiness probe with parallel feature checks - use for K8s readinessProbe
curl http://localhost:8082/health/ready
# Deep diagnostics (parallel feature checks, deep by default)
curl http://localhost:8082/health
# Returns: {"status": "healthy", "timestamp": "2024-..."}
# Pass ?deep=false for an immediate liveness response
```
### Kubernetes Probes
Use the fast liveness endpoint for probes to avoid timeouts:
```yaml
livenessProbe:
httpGet:
path: /health/live
port: 8082
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 1
readinessProbe:
httpGet:
path: /health/ready
port: 8082
initialDelaySeconds: 5
periodSeconds: 10
```
### Docker HEALTHCHECK
@@ -156,7 +184,7 @@ The Dockerfile includes a built-in health check:
```dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:${AIUI_PORT}/health || exit 1
CMD curl -f http://localhost:${AIUI_PORT}/health/live || exit 1
```
### CLI Health Commands
@@ -199,7 +227,7 @@ curl -H "X-API-Key: <key>" http://localhost:8082/api/config
- **Internal networks**: Use `AUTH_ENFORCE=false` if behind a trusted reverse proxy
- **Public exposure**: Always set `AUTH_ENFORCE=true` and use HTTPS
- **Load balancers**: Health check path is `/health` (exempt from auth)
- **Load balancers**: Health check path is `/health/live` (fast, exempt from auth)
## Managed Hosting
@@ -209,7 +237,9 @@ Hosting platforms should expose these endpoints:
| Endpoint | Purpose |
|----------|---------|
| `/health` | Health check (returns `{"status": "healthy"}`) |
| `/health/live` | Fast liveness probe (returns `{"status": "ok"}`) |
| `/health/ready` | Readiness probe (parallel feature checks) |
| `/health` | Deep health (parallel feature checks; `?deep=false` for liveness) |
| `/api/protocol` | Protocol version and capabilities |
| `/api/protocol/negotiate` | Capability negotiation |
| `/api/features` | List of registered features |
+3 -3
View File
@@ -1859,7 +1859,7 @@ def health_check(
from praisonaiui.health_utils import is_success_status
try:
with urlopen(f"{server}/health") as resp:
with urlopen(f"{server}/health/live") as resp:
data = _json.loads(resp.read())
status = data.get("status", "unknown")
ts = data.get("timestamp", "")
@@ -1988,7 +1988,7 @@ def provider_health(
from urllib.request import urlopen
try:
with urlopen(f"{server}/health") as resp:
with urlopen(f"{server}/health/ready?deep=false") as resp:
data = _json.loads(resp.read())
provider = data.get("provider", {})
console.print(f"[green]✓[/green] Provider: {provider.get('name', 'unknown')}")
@@ -3098,7 +3098,7 @@ def doctor(
return "pass", f"running on {server.split('://')[-1]}"
return "warn", f"status: {status}"
checks.append(_check("Server Health", "/health", _health_extractor))
checks.append(_check("Server Health", "/health/live", _health_extractor))
# Check 2: Provider Status
def _provider_extractor(data):
+30 -13
View File
@@ -941,9 +941,16 @@ def register_page_action(page_id: str):
async def health(request: Request) -> JSONResponse:
"""Health check endpoint - deep health with feature checks.
For backward compatibility, performs deep health checks on all features.
Use /health/live for fast liveness checks or /health/ready for parallel checks.
Performs deep health checks on all features in parallel with a per-feature
timeout so a single slow feature cannot block the whole response. Pass
?deep=false for an immediate liveness response. Use /health/live for the
fastest liveness probe or /health/ready for the readiness probe.
"""
import asyncio
if request.query_params.get("deep", "true").lower() == "false":
return await health_live(request)
provider = get_provider()
provider_info = {"name": type(provider).__name__}
try:
@@ -959,18 +966,28 @@ async def health(request: Request) -> JSONResponse:
from praisonaiui.features import get_features
integrated_flag = is_integrated_mode()
for name, feat in get_features().items():
async def _safe_health(name: str, feat) -> tuple[str, Dict[str, Any]]:
try:
fh = await feat.health()
if fh.get("sdk_gap"):
sdk_gaps.append(
{
"feature": name,
"message": fh.get("sdk_gap_message", "SDK gap"),
}
)
except Exception:
continue
result = await asyncio.wait_for(feat.health(), timeout=0.5)
return (name, result)
except asyncio.TimeoutError:
return (name, {"healthy": False, "detail": "timeout"})
except Exception as e:
return (name, {"healthy": False, "detail": str(e)})
features = get_features()
health_tasks = [_safe_health(name, feat) for name, feat in features.items()]
results = await asyncio.gather(*health_tasks)
for name, fh in results:
if fh.get("sdk_gap"):
sdk_gaps.append(
{
"feature": name,
"message": fh.get("sdk_gap_message", "SDK gap"),
}
)
except Exception:
pass
+2 -2
View File
@@ -68,7 +68,7 @@ class TestDoctorCommand:
def test_doctor_health_extractor_accepts_success_statuses(self, status):
"""Test doctor command's health extractor accepts both ok and healthy."""
def mock_api_get(server, path):
if path == "/health":
if path == "/health/live":
return {"status": status}
elif path == "/api/provider":
return {"name": "TestProvider"}
@@ -102,7 +102,7 @@ class TestDoctorCommand:
def test_doctor_health_extractor_warns_for_non_success_statuses(self, status):
"""Test doctor command's health extractor warns for non-success statuses."""
def mock_api_get(server, path):
if path == "/health":
if path == "/health/live":
return {"status": status}
return {}
+43
View File
@@ -102,6 +102,49 @@ class TestHealthEndpoint:
response = client.get(endpoint)
assert response.status_code == 200
def test_health_live_under_500ms(self, client):
"""Liveness probe must respond well under 500ms."""
import time
start = time.perf_counter()
response = client.get("/health/live")
duration = time.perf_counter() - start
assert response.status_code == 200
assert response.json()["status"] == "ok"
assert duration < 0.5
def test_health_deep_false_is_liveness(self, client):
"""Legacy /health?deep=false returns an immediate liveness response."""
response = client.get("/health?deep=false")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert "timestamp" in data
assert "provider" not in data
def test_health_slow_feature_does_not_block(self, client):
"""A slow feature must not block the legacy /health response."""
import asyncio
import time
from praisonaiui.features import _features
class _SlowFeature:
async def health(self):
await asyncio.sleep(5)
return {"healthy": True}
_features["__slow_test__"] = _SlowFeature()
try:
start = time.perf_counter()
response = client.get("/health")
duration = time.perf_counter() - start
assert response.status_code == 200
assert duration < 2.0
finally:
_features.pop("__slow_test__", None)
class TestAgentsEndpoint:
"""Tests for agents endpoint."""