mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
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:
5 files changed
+114
-24
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user