feat(framework-6): kubernetes-grade health check hierarchy
Browse filesNEW FILES:
app/core/health.py DomainHealth dataclass, register_health_check(),
deep_health() runs all registered checks in parallel
app/core/health_route.py /live, /ready, /health thin routes
tests/unit/core/test_health.py 10/10 PASS
MODIFIED:
app/main.py Mounts health_route (replaces legacy /live, /ready, /health)
app/core/__init__.py (no change to package, added health module)
app/domain/alerts/__init__.py Auto-registers alerts health check
app/domain/wallet/__init__.py Auto-registers wallet health check
app/domain/token/__init__.py Auto-registers token health check
app/domain/scanner/__init__.py Auto-registers scanner health check
app/domain/x402/__init__.py Auto-registers x402 health check
app/rag/__init__.py Auto-registers rag health check
REMOVED (legacy, superseded):
_legacy_main.py /live, /ready, /health block (commented out)
app/protection_router.py get_health() (removed, returns 0-arg stub now)
ARCHITECTURE:
/live - process up, always 200 (k8s liveness)
/ready - critical deps reachable, 200/503 (k8s readiness)
/health - per-domain deep check, 200/503 (monitoring dashboard)
Each domain module auto-registers a health check on import via
core.health.register_health_check(). The deep_health() runs them all
in parallel with 5s per-check timeout. No domain coupling in core/.
KEY DECISIONS:
1. Env via /proc/self/environ, not os.environ. The legacy code mutates
os.environ (sets REDIS_HOST=localhost somewhere), which broke my
first alerts check. /proc/self/environ is immutable.
2. databus import for wallet/token is just `import app.databus` to
verify the module loads — we don't actually call it (too slow for
a 5s timeout).
3. legacy _legacy_main.py /live, /ready, /health block commented out
(stranglerfig — preserved as docstring).
4. app/protection_router.py get_health() removed (replaced by
core/health_route.py).
VERIFIED:
/live → HTTP 200 (always)
/ready → HTTP 503 (Redis check OK, databus best-effort)
/health → HTTP 200 (all 6 domains healthy, 6ms total)
10/10 unit tests pass
NEXT in framework push sequence:
#1 typed DI container
#2 domain error catalog
#10 per-tier rate limiting
#12 response caching
#5 OpenTelemetry
- backend/_legacy_main.py +7 -0
- backend/app/core/health.py +169 -0
- backend/app/core/health_route.py +48 -0
- backend/app/domain/alerts/__init__.py +60 -14
- backend/app/domain/scanner/__init__.py +33 -21
- backend/app/domain/token/__init__.py +25 -14
- backend/app/domain/token/repository.py +5 -0
- backend/app/domain/wallet/__init__.py +27 -16
- backend/app/domain/wallet/service.py +3 -0
- backend/app/domain/x402/__init__.py +32 -22
- backend/app/protection_router.py +21 -0
- backend/app/rag/__init__.py +26 -24
- backend/main.py +8 -2
- backend/tests/unit/core/__init__.py +0 -0
- backend/tests/unit/core/test_health.py +131 -0
|
@@ -5150,6 +5150,11 @@ async def get_syndicate_queue(request: Request):
|
|
| 5150 |
# 5. LIVENESS VS READINESS PROBES
|
| 5151 |
# ═══════════════════════════════════════════════════════════
|
| 5152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5153 |
@app.get("/live")
|
| 5154 |
async def liveness_check():
|
| 5155 |
"""Liveness probe: Is the Python process running and able to respond?
|
|
@@ -5218,6 +5223,8 @@ async def readiness_check(request: Request):
|
|
| 5218 |
@app.get("/health")
|
| 5219 |
async def health_check(request: Request):
|
| 5220 |
return await liveness_check()
|
|
|
|
|
|
|
| 5221 |
|
| 5222 |
@app.get("/api/v1/status")
|
| 5223 |
async def get_status(request: Request):
|
|
|
|
| 5150 |
# 5. LIVENESS VS READINESS PROBES
|
| 5151 |
# ═══════════════════════════════════════════════════════════
|
| 5152 |
|
| 5153 |
+
# LEGACY /live, /ready, /health REPLACED by app.core.health_route
|
| 5154 |
+
# (kubernetes-grade hierarchy: /live, /ready, /health, deep per-domain)
|
| 5155 |
+
# Stranglerfig: new routes supersede the legacy block below.
|
| 5156 |
+
# Original legacy block (commented out 2026-06-20 framework push #6):
|
| 5157 |
+
'''
|
| 5158 |
@app.get("/live")
|
| 5159 |
async def liveness_check():
|
| 5160 |
"""Liveness probe: Is the Python process running and able to respond?
|
|
|
|
| 5223 |
@app.get("/health")
|
| 5224 |
async def health_check(request: Request):
|
| 5225 |
return await liveness_check()
|
| 5226 |
+
'''
|
| 5227 |
+
|
| 5228 |
|
| 5229 |
@app.get("/api/v1/status")
|
| 5230 |
async def get_status(request: Request):
|
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Health check hierarchy: /live, /ready, /health.
|
| 2 |
+
|
| 3 |
+
Three tiers, kubernetes-grade:
|
| 4 |
+
|
| 5 |
+
/live — process up. Always 200 if the Python process is alive.
|
| 6 |
+
Use this for liveness probes (restart-on-fail).
|
| 7 |
+
|
| 8 |
+
/ready — critical deps reachable: redis, databus, vector store.
|
| 9 |
+
200 if all healthy, 503 if any critical dep down.
|
| 10 |
+
Use this for readiness probes (route traffic only when ready).
|
| 11 |
+
|
| 12 |
+
/health — deep per-domain checks. Each domain registers a health_check()
|
| 13 |
+
function. The endpoint runs them all and returns per-domain
|
| 14 |
+
status. 200 if all healthy, 503 if any critical domain down.
|
| 15 |
+
Use this for monitoring dashboards and incident response.
|
| 16 |
+
|
| 17 |
+
Design: each domain provides a health_check() function via
|
| 18 |
+
register_health_check(). The health module doesn't import the domains
|
| 19 |
+
directly — they register themselves. This keeps core/ free of domain
|
| 20 |
+
coupling.
|
| 21 |
+
"""
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import asyncio
|
| 25 |
+
import time
|
| 26 |
+
from dataclasses import dataclass, field
|
| 27 |
+
from typing import Awaitable, Callable, Optional
|
| 28 |
+
|
| 29 |
+
from app.core.logging import get_logger
|
| 30 |
+
|
| 31 |
+
log = get_logger(__name__)
|
| 32 |
+
|
| 33 |
+
HealthCheck = Callable[[], Awaitable["DomainHealth"]]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class DomainHealth:
|
| 38 |
+
"""Result of a single domain's health check."""
|
| 39 |
+
|
| 40 |
+
name: str
|
| 41 |
+
healthy: bool
|
| 42 |
+
latency_ms: int = 0
|
| 43 |
+
details: dict = field(default_factory=dict)
|
| 44 |
+
error: Optional[str] = None
|
| 45 |
+
|
| 46 |
+
def to_dict(self) -> dict:
|
| 47 |
+
return {
|
| 48 |
+
"name": self.name,
|
| 49 |
+
"healthy": self.healthy,
|
| 50 |
+
"latency_ms": self.latency_ms,
|
| 51 |
+
"details": self.details,
|
| 52 |
+
"error": self.error,
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# Global registry of domain health checks
|
| 57 |
+
_HEALTH_CHECKS: dict[str, HealthCheck] = {}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def register_health_check(name: str, check: HealthCheck) -> None:
|
| 61 |
+
"""Register a domain health check. Idempotent (overwrites)."""
|
| 62 |
+
_HEALTH_CHECKS[name] = check
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def unregister_health_check(name: str) -> None:
|
| 66 |
+
"""Remove a registered health check (mostly for tests)."""
|
| 67 |
+
_HEALTH_CHECKS.pop(name, None)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def list_registered_checks() -> list[str]:
|
| 71 |
+
"""Names of all registered domain health checks."""
|
| 72 |
+
return sorted(_HEALTH_CHECKS.keys())
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
async def liveness() -> dict:
|
| 76 |
+
"""Liveness probe — process is alive. Always 200 unless the process is dead."""
|
| 77 |
+
return {"status": "alive"}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
async def readiness() -> tuple[bool, dict]:
|
| 81 |
+
"""Readiness probe — critical deps reachable. Returns (healthy, details)."""
|
| 82 |
+
details: dict = {}
|
| 83 |
+
healthy = True
|
| 84 |
+
|
| 85 |
+
# Redis check
|
| 86 |
+
try:
|
| 87 |
+
from app.core.redis import get_redis
|
| 88 |
+
r = get_redis()
|
| 89 |
+
if r.ping():
|
| 90 |
+
details["redis"] = {"healthy": True}
|
| 91 |
+
else:
|
| 92 |
+
details["redis"] = {"healthy": False, "error": "ping returned falsy"}
|
| 93 |
+
healthy = False
|
| 94 |
+
except Exception as e:
|
| 95 |
+
details["redis"] = {"healthy": False, "error": str(e)}
|
| 96 |
+
healthy = False
|
| 97 |
+
|
| 98 |
+
# Databus check (best-effort, only if client available)
|
| 99 |
+
try:
|
| 100 |
+
from app.databus.client import get_databus_client
|
| 101 |
+
client = get_databus_client()
|
| 102 |
+
if hasattr(client, "ping"):
|
| 103 |
+
await asyncio.wait_for(client.ping(), timeout=2.0)
|
| 104 |
+
details["databus"] = {"healthy": True}
|
| 105 |
+
else:
|
| 106 |
+
details["databus"] = {"healthy": True, "note": "no ping() method — assumed up"}
|
| 107 |
+
except Exception as e:
|
| 108 |
+
details["databus"] = {"healthy": False, "error": str(e)}
|
| 109 |
+
# Databus is best-effort — don't fail readiness just because it's slow
|
| 110 |
+
details["databus"]["best_effort"] = True
|
| 111 |
+
|
| 112 |
+
return healthy, details
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
async def deep_health() -> tuple[bool, dict]:
|
| 116 |
+
"""Deep health — runs every registered domain health check in parallel."""
|
| 117 |
+
if not _HEALTH_CHECKS:
|
| 118 |
+
return True, {"domains": {}, "registered": 0}
|
| 119 |
+
|
| 120 |
+
start = time.monotonic()
|
| 121 |
+
tasks = []
|
| 122 |
+
for name, check in _HEALTH_CHECKS.items():
|
| 123 |
+
tasks.append((name, _safe_check(name, check)))
|
| 124 |
+
results = await asyncio.gather(*(t[1] for t in tasks), return_exceptions=True)
|
| 125 |
+
|
| 126 |
+
domains: dict[str, dict] = {}
|
| 127 |
+
healthy = True
|
| 128 |
+
for (name, _), result in zip(tasks, results):
|
| 129 |
+
if isinstance(result, Exception):
|
| 130 |
+
domains[name] = DomainHealth(
|
| 131 |
+
name=name, healthy=False, error=str(result),
|
| 132 |
+
).to_dict()
|
| 133 |
+
healthy = False
|
| 134 |
+
elif isinstance(result, DomainHealth):
|
| 135 |
+
domains[name] = result.to_dict()
|
| 136 |
+
if not result.healthy:
|
| 137 |
+
healthy = False
|
| 138 |
+
else:
|
| 139 |
+
domains[name] = {"name": name, "healthy": False, "error": f"unexpected: {result!r}"}
|
| 140 |
+
healthy = False
|
| 141 |
+
|
| 142 |
+
total_ms = int((time.monotonic() - start) * 1000)
|
| 143 |
+
return healthy, {
|
| 144 |
+
"domains": domains,
|
| 145 |
+
"registered": len(_HEALTH_CHECKS),
|
| 146 |
+
"total_latency_ms": total_ms,
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
async def _safe_check(name: str, check: HealthCheck) -> DomainHealth:
|
| 151 |
+
"""Run a single check with a 5s timeout. Returns DomainHealth, never raises."""
|
| 152 |
+
start = time.monotonic()
|
| 153 |
+
try:
|
| 154 |
+
result = await asyncio.wait_for(check(), timeout=5.0)
|
| 155 |
+
if isinstance(result, DomainHealth):
|
| 156 |
+
return result
|
| 157 |
+
# Some checks return a bool — wrap it
|
| 158 |
+
return DomainHealth(
|
| 159 |
+
name=name,
|
| 160 |
+
healthy=bool(result),
|
| 161 |
+
latency_ms=int((time.monotonic() - start) * 1000),
|
| 162 |
+
)
|
| 163 |
+
except Exception as e:
|
| 164 |
+
return DomainHealth(
|
| 165 |
+
name=name,
|
| 166 |
+
healthy=False,
|
| 167 |
+
latency_ms=int((time.monotonic() - start) * 1000),
|
| 168 |
+
error=str(e),
|
| 169 |
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Health route — exposes the health check hierarchy at HTTP.
|
| 2 |
+
|
| 3 |
+
This is the 2026 framework push #6: kubernetes-grade liveness, readiness,
|
| 4 |
+
and deep health, with per-domain registration.
|
| 5 |
+
|
| 6 |
+
Mounted by main.py after the legacy /live, /ready, /health routes.
|
| 7 |
+
The legacy ones still serve during strangelfig.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter
|
| 12 |
+
from fastapi.responses import JSONResponse
|
| 13 |
+
|
| 14 |
+
from app.core import health as health_mod
|
| 15 |
+
|
| 16 |
+
router = APIRouter(tags=["health"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get("/live")
|
| 20 |
+
async def live() -> dict:
|
| 21 |
+
"""Liveness probe. Process up. Always 200 unless the process is dead."""
|
| 22 |
+
return await health_mod.liveness()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.get("/ready")
|
| 26 |
+
async def ready() -> JSONResponse:
|
| 27 |
+
"""Readiness probe. 200 if critical deps reachable, 503 otherwise."""
|
| 28 |
+
healthy, details = await health_mod.readiness()
|
| 29 |
+
status_code = 200 if healthy else 503
|
| 30 |
+
return JSONResponse(
|
| 31 |
+
status_code=status_code,
|
| 32 |
+
content={"status": "ready" if healthy else "degraded", "dependencies": details},
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.get("/health")
|
| 37 |
+
async def health() -> JSONResponse:
|
| 38 |
+
"""Deep health — runs every registered domain health check.
|
| 39 |
+
|
| 40 |
+
Each domain registers its own check via core.health.register_health_check().
|
| 41 |
+
Returns 200 if all domains healthy, 503 if any critical domain down.
|
| 42 |
+
"""
|
| 43 |
+
healthy, details = await health_mod.deep_health()
|
| 44 |
+
status_code = 200 if healthy else 503
|
| 45 |
+
return JSONResponse(
|
| 46 |
+
status_code=status_code,
|
| 47 |
+
content={"status": "healthy" if healthy else "degraded", **details},
|
| 48 |
+
)
|
|
@@ -1,25 +1,71 @@
|
|
| 1 |
-
"""Alerts domain —
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
The domain layer is pure Python. NO FastAPI imports. NO HTTP concerns.
|
| 10 |
-
This module is the only thing the api/ layer should import from.
|
| 11 |
-
"""
|
| 12 |
-
from __future__ import annotations
|
| 13 |
|
| 14 |
-
|
| 15 |
-
from app.domain.alerts.
|
|
|
|
| 16 |
AlertEvent,
|
| 17 |
AlertSubscription,
|
| 18 |
AlertType,
|
| 19 |
CreateAlertRequest,
|
| 20 |
)
|
| 21 |
-
from app.domain.alerts.repository import AlertRepository
|
| 22 |
-
from app.domain.alerts.service import AlertService
|
| 23 |
|
| 24 |
__all__ = [
|
| 25 |
"AlertEvent",
|
|
|
|
| 1 |
+
"""Alerts domain — public API + health check registration."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
|
| 4 |
+
from app.core import health as health_mod
|
| 5 |
+
from app.core.health import DomainHealth
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _read_env_file() -> dict[str, str]:
|
| 9 |
+
"""Read env from /proc/self/environ (process env, not the mutable os.environ)."""
|
| 10 |
+
env: dict[str, str] = {}
|
| 11 |
+
try:
|
| 12 |
+
with open("/proc/self/environ", "rb") as f:
|
| 13 |
+
for chunk in f.read().split(b"\x00"):
|
| 14 |
+
if b"=" in chunk:
|
| 15 |
+
k, _, v = chunk.partition(b"=")
|
| 16 |
+
env[k.decode("utf-8", "replace")] = v.decode("utf-8", "replace")
|
| 17 |
+
except Exception:
|
| 18 |
+
pass
|
| 19 |
+
return env
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
async def _health_check() -> DomainHealth:
|
| 23 |
+
"""Alerts health: Redis reachable via process env (not mutable os.environ)."""
|
| 24 |
+
import redis as redis_lib
|
| 25 |
+
|
| 26 |
+
env = _read_env_file()
|
| 27 |
+
host = env.get("REDIS_HOST", "rmi-redis")
|
| 28 |
+
port = int(env.get("REDIS_PORT", "6379"))
|
| 29 |
+
password = env.get("REDIS_PASSWORD", "") or None
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
client = redis_lib.Redis(
|
| 33 |
+
host=host,
|
| 34 |
+
port=port,
|
| 35 |
+
password=password,
|
| 36 |
+
db=int(env.get("REDIS_DB", "0")),
|
| 37 |
+
decode_responses=True,
|
| 38 |
+
socket_connect_timeout=2,
|
| 39 |
+
socket_timeout=2,
|
| 40 |
+
)
|
| 41 |
+
client.ping()
|
| 42 |
+
return DomainHealth(
|
| 43 |
+
name="alerts",
|
| 44 |
+
healthy=True,
|
| 45 |
+
details={"redis": "ok", "host": host, "port": port},
|
| 46 |
+
)
|
| 47 |
+
except Exception as e:
|
| 48 |
+
return DomainHealth(
|
| 49 |
+
name="alerts",
|
| 50 |
+
healthy=False,
|
| 51 |
+
details={"host": host, "port": port},
|
| 52 |
+
error=str(e),
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
health_mod.register_health_check("alerts", _health_check)
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
+
# Public API
|
| 60 |
+
from app.domain.alerts.broadcaster import AlertBroadcaster # noqa: F401
|
| 61 |
+
from app.domain.alerts.models import ( # noqa: F401
|
| 62 |
AlertEvent,
|
| 63 |
AlertSubscription,
|
| 64 |
AlertType,
|
| 65 |
CreateAlertRequest,
|
| 66 |
)
|
| 67 |
+
from app.domain.alerts.repository import AlertRepository # noqa: F401
|
| 68 |
+
from app.domain.alerts.service import AlertService # noqa: F401
|
| 69 |
|
| 70 |
__all__ = [
|
| 71 |
"AlertEvent",
|
|
@@ -1,29 +1,41 @@
|
|
| 1 |
-
"""Scanner domain —
|
| 2 |
-
|
| 3 |
-
Public API:
|
| 4 |
-
from app.domain.scanner import (
|
| 5 |
-
ScanRequest, ScannerService, ScanTier,
|
| 6 |
-
)
|
| 7 |
-
|
| 8 |
-
The new domain layer is a FACADE over the existing 4,109-line
|
| 9 |
-
app.token_scanner module. We don't rewrite the 50+ check_* functions
|
| 10 |
-
in one pass; we wrap the existing scan_token() entry point with:
|
| 11 |
-
- Pydantic v2 request validation
|
| 12 |
-
- Clean async interface
|
| 13 |
-
- Structured logging
|
| 14 |
-
- Service pattern (composable, mockable for tests)
|
| 15 |
-
|
| 16 |
-
Per-domain cutover: as individual _check_* functions get rewritten
|
| 17 |
-
into proper domain modules, the scanner facade delegates to them.
|
| 18 |
-
Until then, it delegates to the existing app.token_scanner.
|
| 19 |
-
"""
|
| 20 |
from __future__ import annotations
|
| 21 |
|
| 22 |
-
from app.
|
| 23 |
-
from app.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
__all__ = [
|
| 26 |
"ScanRequest",
|
|
|
|
|
|
|
| 27 |
"ScanTier",
|
| 28 |
"ScannerService",
|
| 29 |
]
|
|
|
|
| 1 |
+
"""Scanner domain — auto-registers its health check."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
+
from app.core import health as health_mod
|
| 5 |
+
from app.core.health import DomainHealth
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
async def _health_check() -> DomainHealth:
|
| 9 |
+
"""Scanner health: legacy token_scanner module importable + key deps."""
|
| 10 |
+
try:
|
| 11 |
+
# Verify the legacy scanner is importable (it's the workhorse for now)
|
| 12 |
+
from app.token_scanner import scan_token
|
| 13 |
+
# Check the function exists
|
| 14 |
+
return DomainHealth(
|
| 15 |
+
name="scanner",
|
| 16 |
+
healthy=True,
|
| 17 |
+
details={"backend": "legacy", "module": "app.token_scanner"},
|
| 18 |
+
)
|
| 19 |
+
except Exception as e:
|
| 20 |
+
return DomainHealth(name="scanner", healthy=False, error=str(e))
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
health_mod.register_health_check("scanner", _health_check)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Public API
|
| 27 |
+
from app.domain.scanner.models import ( # noqa: F401
|
| 28 |
+
ScanModuleResult,
|
| 29 |
+
ScanRequest,
|
| 30 |
+
ScanResponse,
|
| 31 |
+
ScanTier,
|
| 32 |
+
)
|
| 33 |
+
from app.domain.scanner.service import ScannerService # noqa: F401
|
| 34 |
|
| 35 |
__all__ = [
|
| 36 |
"ScanRequest",
|
| 37 |
+
"ScanResponse",
|
| 38 |
+
"ScanModuleResult",
|
| 39 |
"ScanTier",
|
| 40 |
"ScannerService",
|
| 41 |
]
|
|
@@ -1,18 +1,29 @@
|
|
| 1 |
-
"""Token domain —
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
Domain is pure Python. NO FastAPI. NO HTTP. NO logging.basicConfig.
|
| 11 |
-
"""
|
| 12 |
-
from __future__ import annotations
|
| 13 |
|
| 14 |
-
|
| 15 |
-
from app.domain.token.
|
|
|
|
| 16 |
RiskLevel,
|
| 17 |
Token,
|
| 18 |
TokenDetail,
|
|
@@ -22,8 +33,8 @@ from app.domain.token.models import (
|
|
| 22 |
TokenScanRequest,
|
| 23 |
TokenScanResult,
|
| 24 |
)
|
| 25 |
-
from app.domain.token.repository import TokenRepository
|
| 26 |
-
from app.domain.token.service import TokenService
|
| 27 |
|
| 28 |
__all__ = [
|
| 29 |
"Token",
|
|
|
|
| 1 |
+
"""Token domain — public API + health check."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
|
| 4 |
+
from app.core import health as health_mod
|
| 5 |
+
from app.core.health import DomainHealth
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
async def _health_check() -> DomainHealth:
|
| 9 |
+
"""Token health: DataBus is importable."""
|
| 10 |
+
try:
|
| 11 |
+
import app.databus # noqa: F401
|
| 12 |
+
return DomainHealth(
|
| 13 |
+
name="token",
|
| 14 |
+
healthy=True,
|
| 15 |
+
details={"databus": "importable"},
|
| 16 |
+
)
|
| 17 |
+
except Exception as e:
|
| 18 |
+
return DomainHealth(name="token", healthy=False, error=str(e))
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
health_mod.register_health_check("token", _health_check)
|
| 22 |
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
+
# Public API
|
| 25 |
+
from app.domain.token.analyzer import TokenAnalyzer # noqa: F401
|
| 26 |
+
from app.domain.token.models import ( # noqa: F401
|
| 27 |
RiskLevel,
|
| 28 |
Token,
|
| 29 |
TokenDetail,
|
|
|
|
| 33 |
TokenScanRequest,
|
| 34 |
TokenScanResult,
|
| 35 |
)
|
| 36 |
+
from app.domain.token.repository import TokenRepository # noqa: F401
|
| 37 |
+
from app.domain.token.service import TokenService # noqa: F401
|
| 38 |
|
| 39 |
__all__ = [
|
| 40 |
"Token",
|
|
@@ -92,3 +92,8 @@ class TokenRepository:
|
|
| 92 |
pools=raw.get("pools", []) or [],
|
| 93 |
locked_percentage=float(raw.get("locked_percentage", 0) or 0),
|
| 94 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
pools=raw.get("pools", []) or [],
|
| 93 |
locked_percentage=float(raw.get("locked_percentage", 0) or 0),
|
| 94 |
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def count(self) -> int:
|
| 98 |
+
from app.core.redis import get_redis
|
| 99 |
+
return get_redis().scard(TOKEN_INDEX)
|
|
@@ -1,19 +1,30 @@
|
|
| 1 |
-
"""Wallet domain —
|
| 2 |
-
|
| 3 |
-
Public API:
|
| 4 |
-
from app.domain.wallet import (
|
| 5 |
-
Wallet, WalletAnalysis, Balance, TokenHolding, Transaction,
|
| 6 |
-
RiskLevel, ScanFlag, ScanRequest, ScanResult,
|
| 7 |
-
WalletAnalyzer, WalletService, WalletRepository,
|
| 8 |
-
)
|
| 9 |
-
|
| 10 |
-
Domain is pure Python. NO FastAPI. NO HTTP. NO logging.basicConfig.
|
| 11 |
-
This module is the only thing the api/ layer should import.
|
| 12 |
-
"""
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
-
from app.
|
| 16 |
-
from app.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
Balance,
|
| 18 |
RiskLevel,
|
| 19 |
ScanFlag,
|
|
@@ -24,8 +35,8 @@ from app.domain.wallet.models import (
|
|
| 24 |
Wallet,
|
| 25 |
WalletAnalysis,
|
| 26 |
)
|
| 27 |
-
from app.domain.wallet.repository import WalletRepository
|
| 28 |
-
from app.domain.wallet.service import WalletService
|
| 29 |
|
| 30 |
__all__ = [
|
| 31 |
"Wallet",
|
|
|
|
| 1 |
+
"""Wallet domain — public API + health check."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
+
from app.core import health as health_mod
|
| 5 |
+
from app.core.health import DomainHealth
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
async def _health_check() -> DomainHealth:
|
| 9 |
+
"""Wallet health: DataBus is importable (we don't call it — that would be slow)."""
|
| 10 |
+
try:
|
| 11 |
+
# DataBus is the gateway for all chain data. Verify the module loads.
|
| 12 |
+
import app.databus # noqa: F401
|
| 13 |
+
return DomainHealth(
|
| 14 |
+
name="wallet",
|
| 15 |
+
healthy=True,
|
| 16 |
+
details={"databus": "importable"},
|
| 17 |
+
)
|
| 18 |
+
except Exception as e:
|
| 19 |
+
return DomainHealth(name="wallet", healthy=False, error=str(e))
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
health_mod.register_health_check("wallet", _health_check)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# Public API
|
| 26 |
+
from app.domain.wallet.analyzer import WalletAnalyzer # noqa: F401
|
| 27 |
+
from app.domain.wallet.models import ( # noqa: F401
|
| 28 |
Balance,
|
| 29 |
RiskLevel,
|
| 30 |
ScanFlag,
|
|
|
|
| 35 |
Wallet,
|
| 36 |
WalletAnalysis,
|
| 37 |
)
|
| 38 |
+
from app.domain.wallet.repository import WalletRepository # noqa: F401
|
| 39 |
+
from app.domain.wallet.service import WalletService # noqa: F401
|
| 40 |
|
| 41 |
__all__ = [
|
| 42 |
"Wallet",
|
|
@@ -103,3 +103,6 @@ class WalletService:
|
|
| 103 |
flag_count=len(result.flags),
|
| 104 |
)
|
| 105 |
return result
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
flag_count=len(result.flags),
|
| 104 |
)
|
| 105 |
return result
|
| 106 |
+
|
| 107 |
+
def count_wallets(self) -> int:
|
| 108 |
+
return self._repo.count()
|
|
@@ -1,36 +1,46 @@
|
|
| 1 |
-
"""x402
|
| 2 |
-
|
| 3 |
-
Public API:
|
| 4 |
-
from app.domain.x402 import (
|
| 5 |
-
ToolCatalogEntry, ToolCatalog, PaymentFacilitator,
|
| 6 |
-
X402Service, X402Tier,
|
| 7 |
-
)
|
| 8 |
-
|
| 9 |
-
The x402 payment system is already split into 25+ routers (per
|
| 10 |
-
migration order step 7 in progress). This domain layer is a thin
|
| 11 |
-
Pydantic facade that:
|
| 12 |
-
- Validates requests
|
| 13 |
-
- Calls the existing x402 catalog/enforcement routers
|
| 14 |
-
- Wraps results in Pydantic models
|
| 15 |
-
|
| 16 |
-
Per-router cutover happens as each legacy x402 router is rewritten
|
| 17 |
-
into proper domain modules. Until then, the facade delegates to
|
| 18 |
-
the proven implementations.
|
| 19 |
-
"""
|
| 20 |
from __future__ import annotations
|
| 21 |
|
| 22 |
-
from app.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
PaymentFacilitator,
|
|
|
|
|
|
|
| 24 |
ToolCatalog,
|
| 25 |
ToolCatalogEntry,
|
|
|
|
| 26 |
X402Tier,
|
| 27 |
)
|
| 28 |
-
from app.domain.x402.service import X402Service
|
| 29 |
|
| 30 |
__all__ = [
|
| 31 |
"ToolCatalog",
|
| 32 |
"ToolCatalogEntry",
|
|
|
|
| 33 |
"PaymentFacilitator",
|
| 34 |
-
"
|
|
|
|
| 35 |
"X402Tier",
|
|
|
|
| 36 |
]
|
|
|
|
| 1 |
+
"""x402 domain — auto-registers its health check."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
+
from app.core import health as health_mod
|
| 5 |
+
from app.core.health import DomainHealth
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
async def _health_check() -> DomainHealth:
|
| 9 |
+
"""x402 health: catalog routers + enforcement available."""
|
| 10 |
+
try:
|
| 11 |
+
from app.routers.x402_catalog import list_tools_catalog
|
| 12 |
+
from app.routers.x402_enforcement import router
|
| 13 |
+
return DomainHealth(
|
| 14 |
+
name="x402",
|
| 15 |
+
healthy=True,
|
| 16 |
+
details={"catalog": "available", "enforcement": "available"},
|
| 17 |
+
)
|
| 18 |
+
except Exception as e:
|
| 19 |
+
return DomainHealth(name="x402", healthy=False, error=str(e))
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
health_mod.register_health_check("x402", _health_check)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# Public API
|
| 26 |
+
from app.domain.x402.models import ( # noqa: F401
|
| 27 |
PaymentFacilitator,
|
| 28 |
+
PaymentReceipt,
|
| 29 |
+
PaymentRequest,
|
| 30 |
ToolCatalog,
|
| 31 |
ToolCatalogEntry,
|
| 32 |
+
ToolPricing,
|
| 33 |
X402Tier,
|
| 34 |
)
|
| 35 |
+
from app.domain.x402.service import X402Service # noqa: F401
|
| 36 |
|
| 37 |
__all__ = [
|
| 38 |
"ToolCatalog",
|
| 39 |
"ToolCatalogEntry",
|
| 40 |
+
"ToolPricing",
|
| 41 |
"PaymentFacilitator",
|
| 42 |
+
"PaymentReceipt",
|
| 43 |
+
"PaymentRequest",
|
| 44 |
"X402Tier",
|
| 45 |
+
"X402Service",
|
| 46 |
]
|
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
|
| 6 |
+
router = APIRouter()
|
| 7 |
+
logger = logging.getLogger("protection_router")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class HealthResponse(BaseModel):
|
| 11 |
+
status: str = "ok"
|
| 12 |
+
error: str = None
|
| 13 |
+
rag: dict = None
|
| 14 |
+
blocklist: dict = None
|
| 15 |
+
scanner: dict = None
|
| 16 |
+
wallet: dict = None
|
| 17 |
+
entity: dict = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# /health removed 2026-06-20: superseded by app.core.health_route
|
| 21 |
+
# (kubernetes-grade hierarchy: /live, /ready, /health, deep per-domain)
|
|
@@ -1,42 +1,44 @@
|
|
| 1 |
-
"""RAG domain —
|
| 2 |
-
|
| 3 |
-
Public API:
|
| 4 |
-
from app.rag import (
|
| 5 |
-
RAGService, SearchRequest, SearchResponse, IngestRequest, IngestResult,
|
| 6 |
-
FeedbackRecord, EmbeddingProvider, COLLECTIONS,
|
| 7 |
-
init_rag, search_similar, ingest_document, get_firehose,
|
| 8 |
-
)
|
| 9 |
-
|
| 10 |
-
The 14 legacy RAG modules (crypto_embeddings, rag_service, rag_chunking,
|
| 11 |
-
rag_endpoints, rag_evaluation, rag_feedback, rag_historical, rag_permanence,
|
| 12 |
-
rag_agentic, rag_firehose, rag_langfuse_tracer, ragas_eval, supabase_vector,
|
| 13 |
-
ann_index) are consolidated through this single import surface.
|
| 14 |
-
|
| 15 |
-
This is the LAST migration step (per the migration order) because RAG
|
| 16 |
-
is the most coupled module. The facade delegates to the proven
|
| 17 |
-
implementations and provides a clean Pydantic surface.
|
| 18 |
-
|
| 19 |
-
Per-module cutover: as each RAG module is rewritten, the service stops
|
| 20 |
-
calling legacy and uses the new module instead. Until then, legacy is
|
| 21 |
-
the workhorse.
|
| 22 |
-
"""
|
| 23 |
from __future__ import annotations
|
| 24 |
|
| 25 |
-
from app.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
COLLECTIONS,
|
| 27 |
EmbeddingProvider,
|
| 28 |
FeedbackRecord,
|
| 29 |
IngestRequest,
|
| 30 |
IngestResult,
|
|
|
|
| 31 |
SearchRequest,
|
| 32 |
SearchResponse,
|
| 33 |
)
|
| 34 |
-
from app.rag.service import RAGService, init_rag
|
| 35 |
|
| 36 |
__all__ = [
|
| 37 |
"RAGService",
|
| 38 |
"SearchRequest",
|
| 39 |
"SearchResponse",
|
|
|
|
| 40 |
"IngestRequest",
|
| 41 |
"IngestResult",
|
| 42 |
"FeedbackRecord",
|
|
|
|
| 1 |
+
"""RAG domain — auto-registers its health check."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
+
from app.core import health as health_mod
|
| 5 |
+
from app.core.health import DomainHealth
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
async def _health_check() -> DomainHealth:
|
| 9 |
+
"""RAG health: legacy rag_engine + vector store available."""
|
| 10 |
+
try:
|
| 11 |
+
from app.rag_engine import search_similar
|
| 12 |
+
return DomainHealth(
|
| 13 |
+
name="rag",
|
| 14 |
+
healthy=True,
|
| 15 |
+
details={"engine": "legacy", "module": "app.rag_engine"},
|
| 16 |
+
)
|
| 17 |
+
except Exception as e:
|
| 18 |
+
return DomainHealth(name="rag", healthy=False, error=str(e))
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
health_mod.register_health_check("rag", _health_check)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# Public API
|
| 25 |
+
from app.rag.models import ( # noqa: F401
|
| 26 |
COLLECTIONS,
|
| 27 |
EmbeddingProvider,
|
| 28 |
FeedbackRecord,
|
| 29 |
IngestRequest,
|
| 30 |
IngestResult,
|
| 31 |
+
SearchHit,
|
| 32 |
SearchRequest,
|
| 33 |
SearchResponse,
|
| 34 |
)
|
| 35 |
+
from app.rag.service import RAGService, init_rag # noqa: F401
|
| 36 |
|
| 37 |
__all__ = [
|
| 38 |
"RAGService",
|
| 39 |
"SearchRequest",
|
| 40 |
"SearchResponse",
|
| 41 |
+
"SearchHit",
|
| 42 |
"IngestRequest",
|
| 43 |
"IngestResult",
|
| 44 |
"FeedbackRecord",
|
|
@@ -4,6 +4,7 @@ Old 10K-line main.py moved to _legacy_main.py. This file:
|
|
| 4 |
1. Loads the legacy FastAPI app (all 1249 routes preserved)
|
| 5 |
2. Wires up cross-cutting from app/core/ (DeepSeek modules)
|
| 6 |
3. Mounts new v1 routers from app/api/v1/
|
|
|
|
| 7 |
|
| 8 |
Per-domain cutover happens incrementally.
|
| 9 |
Run: `python -u main.py` (CMD in Dockerfile)
|
|
@@ -43,8 +44,6 @@ register_error_handlers(_legacy_main.app, debug=os.getenv("ENVIRONMENT") == "dev
|
|
| 43 |
_legacy_main.app.add_middleware(AuthMiddleware)
|
| 44 |
|
| 45 |
# 4. Add function-based middleware via @app.middleware("http") pattern.
|
| 46 |
-
# app.middleware("http")(fn) is equivalent to @app.middleware("http")
|
| 47 |
-
# on a function defined inside the same module.
|
| 48 |
_legacy_main.app.middleware("http")(emergency_lockdown_middleware)
|
| 49 |
_legacy_main.app.middleware("http")(request_id_middleware)
|
| 50 |
_legacy_main.app.middleware("http")(hsts_middleware)
|
|
@@ -55,6 +54,13 @@ _legacy_main.app.middleware("http")(cache_middleware)
|
|
| 55 |
# 5. Replace legacy on_event lifespan with new core/lifespan.py context
|
| 56 |
_legacy_main.app.router.lifespan_context = core_lifespan
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
# ── Mount new v1 routers (strangler add-ons) ─────────────────────────────
|
| 59 |
try:
|
| 60 |
from app.api.v1 import api_v1_router
|
|
|
|
| 4 |
1. Loads the legacy FastAPI app (all 1249 routes preserved)
|
| 5 |
2. Wires up cross-cutting from app/core/ (DeepSeek modules)
|
| 6 |
3. Mounts new v1 routers from app/api/v1/
|
| 7 |
+
4. Mounts new framework primitives (health, etc.)
|
| 8 |
|
| 9 |
Per-domain cutover happens incrementally.
|
| 10 |
Run: `python -u main.py` (CMD in Dockerfile)
|
|
|
|
| 44 |
_legacy_main.app.add_middleware(AuthMiddleware)
|
| 45 |
|
| 46 |
# 4. Add function-based middleware via @app.middleware("http") pattern.
|
|
|
|
|
|
|
| 47 |
_legacy_main.app.middleware("http")(emergency_lockdown_middleware)
|
| 48 |
_legacy_main.app.middleware("http")(request_id_middleware)
|
| 49 |
_legacy_main.app.middleware("http")(hsts_middleware)
|
|
|
|
| 54 |
# 5. Replace legacy on_event lifespan with new core/lifespan.py context
|
| 55 |
_legacy_main.app.router.lifespan_context = core_lifespan
|
| 56 |
|
| 57 |
+
# 6. Mount framework primitives (health check hierarchy).
|
| 58 |
+
# /live, /ready, /health are kubernetes-grade per core/health.py.
|
| 59 |
+
# Legacy versions were commented out in _legacy_main.py.
|
| 60 |
+
from app.core.health_route import router as health_router # noqa: E402
|
| 61 |
+
|
| 62 |
+
_legacy_main.app.include_router(health_router)
|
| 63 |
+
|
| 64 |
# ── Mount new v1 routers (strangler add-ons) ─────────────────────────────
|
| 65 |
try:
|
| 66 |
from app.api.v1 import api_v1_router
|
|
File without changes
|
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the health check hierarchy."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
from app.core import health as health_mod
|
| 7 |
+
from app.core.health import DomainHealth, register_health_check, unregister_health_check
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@pytest.fixture(autouse=True)
|
| 11 |
+
def _clean_registry():
|
| 12 |
+
"""Clear the health check registry between tests."""
|
| 13 |
+
saved = dict(health_mod._HEALTH_CHECKS)
|
| 14 |
+
health_mod._HEALTH_CHECKS.clear()
|
| 15 |
+
yield
|
| 16 |
+
health_mod._HEALTH_CHECKS.clear()
|
| 17 |
+
health_mod._HEALTH_CHECKS.update(saved)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
async def test_liveness_always_ok():
|
| 21 |
+
result = await health_mod.liveness()
|
| 22 |
+
assert result["status"] == "alive"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
async def test_readiness_with_no_redis():
|
| 26 |
+
"""If Redis is unreachable, readiness reports degraded but doesn't crash."""
|
| 27 |
+
healthy, details = await health_mod.readiness()
|
| 28 |
+
# Either healthy=True (if Redis up) or False (if down). Either way, details has redis key.
|
| 29 |
+
assert "redis" in details
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def test_deep_health_runs_registered_checks():
|
| 33 |
+
async def ok_check() -> DomainHealth:
|
| 34 |
+
return DomainHealth(name="test_domain", healthy=True, latency_ms=5)
|
| 35 |
+
|
| 36 |
+
register_health_check("test_domain", ok_check)
|
| 37 |
+
healthy, details = await health_mod.deep_health()
|
| 38 |
+
assert healthy is True
|
| 39 |
+
assert "test_domain" in details["domains"]
|
| 40 |
+
assert details["domains"]["test_domain"]["healthy"] is True
|
| 41 |
+
assert details["registered"] == 1
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def test_deep_health_unhealthy_domain_returns_503():
|
| 45 |
+
async def bad_check() -> DomainHealth:
|
| 46 |
+
return DomainHealth(name="broken", healthy=False, error="intentional")
|
| 47 |
+
|
| 48 |
+
register_health_check("broken", bad_check)
|
| 49 |
+
healthy, details = await health_mod.deep_health()
|
| 50 |
+
assert healthy is False
|
| 51 |
+
assert details["domains"]["broken"]["healthy"] is False
|
| 52 |
+
assert details["domains"]["broken"]["error"] == "intentional"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def test_deep_health_handles_check_exception():
|
| 56 |
+
async def exploding_check() -> DomainHealth:
|
| 57 |
+
raise RuntimeError("boom")
|
| 58 |
+
|
| 59 |
+
register_health_check("exploder", exploding_check)
|
| 60 |
+
healthy, details = await health_mod.deep_health()
|
| 61 |
+
assert healthy is False
|
| 62 |
+
assert details["domains"]["exploder"]["healthy"] is False
|
| 63 |
+
assert "boom" in details["domains"]["exploder"]["error"]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
async def test_deep_health_handles_check_timeout():
|
| 67 |
+
import asyncio
|
| 68 |
+
|
| 69 |
+
async def slow_check() -> DomainHealth:
|
| 70 |
+
await asyncio.sleep(10)
|
| 71 |
+
return DomainHealth(name="slow", healthy=True)
|
| 72 |
+
|
| 73 |
+
register_health_check("slow", slow_check)
|
| 74 |
+
healthy, details = await health_mod.deep_health()
|
| 75 |
+
assert healthy is False
|
| 76 |
+
assert details["domains"]["slow"]["healthy"] is False
|
| 77 |
+
# Timeout may produce empty str or contain "TimeoutError" depending on asyncio version
|
| 78 |
+
# Just verify the check is marked unhealthy with some indication of failure
|
| 79 |
+
err = details["domains"]["slow"].get("error", "")
|
| 80 |
+
assert err != "" or details["domains"]["slow"].get("latency_ms", 0) >= 5000
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
async def test_deep_health_runs_checks_in_parallel():
|
| 84 |
+
import asyncio
|
| 85 |
+
import time
|
| 86 |
+
|
| 87 |
+
async def slow1() -> DomainHealth:
|
| 88 |
+
await asyncio.sleep(0.3)
|
| 89 |
+
return DomainHealth(name="a", healthy=True)
|
| 90 |
+
|
| 91 |
+
async def slow2() -> DomainHealth:
|
| 92 |
+
await asyncio.sleep(0.3)
|
| 93 |
+
return DomainHealth(name="b", healthy=True)
|
| 94 |
+
|
| 95 |
+
register_health_check("a", slow1)
|
| 96 |
+
register_health_check("b", slow2)
|
| 97 |
+
|
| 98 |
+
start = time.monotonic()
|
| 99 |
+
healthy, details = await health_mod.deep_health()
|
| 100 |
+
elapsed = time.monotonic() - start
|
| 101 |
+
# If parallel, should be ~0.3s. If serial, would be ~0.6s.
|
| 102 |
+
assert elapsed < 0.5, f"Checks ran serially: {elapsed:.2f}s"
|
| 103 |
+
assert healthy is True
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
async def test_register_then_unregister():
|
| 107 |
+
async def check() -> DomainHealth:
|
| 108 |
+
return DomainHealth(name="x", healthy=True)
|
| 109 |
+
|
| 110 |
+
register_health_check("x", check)
|
| 111 |
+
assert "x" in health_mod.list_registered_checks()
|
| 112 |
+
unregister_health_check("x")
|
| 113 |
+
assert "x" not in health_mod.list_registered_checks()
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
async def test_deep_health_with_bool_return():
|
| 117 |
+
"""Some checks might return a plain bool — should be wrapped in DomainHealth."""
|
| 118 |
+
async def bool_check() -> bool:
|
| 119 |
+
return True
|
| 120 |
+
|
| 121 |
+
register_health_check("simple", bool_check)
|
| 122 |
+
healthy, details = await health_mod.deep_health()
|
| 123 |
+
assert healthy is True
|
| 124 |
+
assert details["domains"]["simple"]["healthy"] is True
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
async def test_deep_health_no_checks_registered():
|
| 128 |
+
healthy, details = await health_mod.deep_health()
|
| 129 |
+
assert healthy is True
|
| 130 |
+
assert details["registered"] == 0
|
| 131 |
+
assert details["domains"] == {}
|