rmi-backend/app/routers/facilitator_health.py

230 lines
6.4 KiB
Python

"""
RMI Facilitator Health Monitor
===============================
Periodically pings all x402 payment facilitators and tracks:
- Latency (ms)
- Success rate (% of last 100 pings)
- Last seen timestamp
- Status (healthy/degraded/dead)
Auto-failover: enforcement middleware reads health scores and routes
around degraded/dead facilitators.
Author: RMI Development
Date: 2026-06-05
"""
import logging
from datetime import UTC, datetime
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from app.core.redis import get_redis
logger = logging.getLogger("facilitator_health")
router = APIRouter(prefix="/api/v1/x402/facilitator-health", tags=["facilitator-health"])
# ── Facilitator Definitions ───────────────────────────────────────
FACILITATORS = {
"payai": {
"name": "PayAI",
"health_url": "https://facilitator.payai.network/health",
"chains": [
"base",
"solana",
"ethereum",
"arbitrum",
"optimism",
"polygon",
"avalanche",
"bsc",
"fantom",
"gnosis",
],
"priority": 1,
"timeout_ms": 5000,
},
"coinbase_cdp": {
"name": "Coinbase CDP",
"health_url": None, # No public health endpoint — check via payment verification
"chains": ["base", "ethereum"],
"priority": 2,
"timeout_ms": 10000,
},
"cloudflare_x402": {
"name": "Cloudflare x402",
"health_url": None, # Check via worker health
"chains": ["base", "ethereum", "arbitrum", "optimism", "polygon"],
"priority": 3,
"timeout_ms": 5000,
},
"primev": {
"name": "Primev",
"health_url": None,
"chains": ["ethereum"],
"priority": 4,
"timeout_ms": 10000,
},
"eip7702": {
"name": "EIP-7702",
"health_url": None,
"chains": [
"ethereum",
"base",
"arbitrum",
"optimism",
"polygon",
"avalanche",
"bsc",
"fantom",
"gnosis",
],
"priority": 5,
"timeout_ms": 10000,
},
"asterpay": {
"name": "AsterPay (SEPA/EUR)",
"health_url": None,
"chains": ["sepa"],
"priority": 6,
"timeout_ms": 10000,
},
"x402_rs": {
"name": "x402-rs (self-hosted)",
"health_url": None,
"chains": ["base", "ethereum"],
"priority": 7,
"timeout_ms": 5000,
},
# OFFLINE facilitators — tracked but marked dead
"pieverse": {
"name": "Pieverse",
"health_url": None,
"chains": [],
"priority": 99,
"timeout_ms": 5000,
"status_override": "dead",
},
"merx_tron": {
"name": "MERX TRON",
"health_url": None,
"chains": [],
"priority": 99,
"timeout_ms": 5000,
"status_override": "dead",
},
"satoshi": {
"name": "Satoshi",
"health_url": None,
"chains": [],
"priority": 99,
"timeout_ms": 5000,
"status_override": "dead",
},
}
# ── Redis Helper ─────────────────────────────────────────────────
async def get_all_health():
"""Get health status for all facilitators."""
result = {}
for name in FACILITATORS:
result[name] = get_facilitator_health(name)
# Summary
healthy = sum(1 for v in result.values() if v.get("status") == "healthy")
degraded = sum(1 for v in result.values() if v.get("status") == "degraded")
dead = sum(1 for v in result.values() if v.get("status") in ("dead",))
return JSONResponse(
content={
"facilitators": result,
"summary": {
"total": len(FACILITATORS),
"healthy": healthy,
"degraded": degraded,
"dead": dead,
"offline": sum(1 for v in FACILITATORS.values() if v.get("status_override") == "dead"),
},
"timestamp": datetime.now(UTC).isoformat(),
}
)
@router.get("/status/{name}")
async def get_facilitator_status(name: str):
"""Get health status for a specific facilitator."""
if name not in FACILITATORS:
return JSONResponse(
status_code=404,
content={
"error": f"Facilitator '{name}' not found",
"available": list(FACILITATORS.keys()),
},
)
return JSONResponse(content=get_facilitator_health(name))
@router.get("/chains/{chain}")
async def get_facilitators_for_chain(chain: str):
"""Get healthy facilitators for a specific chain."""
facilitators = get_healthy_facilitators_for_chain(chain)
return JSONResponse(
content={
"chain": chain,
"facilitators": facilitators,
"count": len(facilitators),
}
)
@router.post("/check/{name}")
async def trigger_health_check(name: str):
"""Manually trigger a health check for a facilitator."""
if name not in FACILITATORS:
return JSONResponse(
status_code=404,
content={"error": f"Facilitator '{name}' not found"},
)
config = FACILITATORS[name]
result = await ping_facilitator(name, config)
record_health(name, result)
return JSONResponse(
content={
"facilitator": name,
"result": result,
}
)
# ── Payment Success Tracking ─────────────────────────────────────
def record_payment_success(facilitator_name: str, success: bool):
"""Record whether a payment via this facilitator succeeded or failed.
Called by the payment verification code after each payment attempt.
"""
r = get_redis()
if not r:
return
total_key = f"rmi:facilitator:{facilitator_name}:payment_total"
success_key = f"rmi:facilitator:{facilitator_name}:payment_success"
r.incr(total_key)
if success:
r.incr(success_key)
# Decay old counters (reset daily)
today = datetime.now(UTC).strftime("%Y-%m-%d")
r.incr(f"rmi:facilitator:{facilitator_name}:payment_total:{today}")
if success:
r.incr(f"rmi:facilitator:{facilitator_name}:payment_success:{today}")