feat(V2): /health/ready con ping client + middleware request log strutturato + request_id correlation
- /health/ready: ping di tutti i client (exchange, env) cached con timeout 2s, status ready|degraded|not_ready, opt-in 503 via READY_FAILS_ON_DEGRADED. - Middleware mcp.request: 1 riga JSON per HTTP request con request_id, method, path, status_code, duration_ms, actor, bot_tag, exchange, tool, client_ip, user_agent. - request_id propagato in request.state, audit log e error envelope per correlazione cross-cutting. - Aggiunto async health() come probe minimo a bybit/alpaca/macro/ sentiment/deribit (hyperliquid lo aveva già). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
"""Factory FastAPI app con middleware, swagger, exception handlers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
@@ -18,6 +20,7 @@ from cerbero_mcp.common.errors import (
|
||||
RETRYABLE_STATUSES,
|
||||
error_envelope,
|
||||
)
|
||||
from cerbero_mcp.common.request_log import install_request_log_middleware
|
||||
|
||||
|
||||
class _TimestampInjectorMiddleware(BaseHTTPMiddleware):
|
||||
@@ -99,6 +102,11 @@ def build_app(
|
||||
app, testnet_token=testnet_token, mainnet_token=mainnet_token
|
||||
)
|
||||
|
||||
# Request log middleware: registrato DOPO auth → starlette esegue
|
||||
# i middleware in ordine inverso (LIFO) → request_log è outermost,
|
||||
# auth è interno e popola request.state.* prima del ritorno.
|
||||
install_request_log_middleware(app)
|
||||
|
||||
app.add_middleware(_TimestampInjectorMiddleware)
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -128,6 +136,7 @@ def build_app(
|
||||
content=error_envelope(
|
||||
type_="http_error", code=code, message=message,
|
||||
retryable=retryable, details=details,
|
||||
request_id=getattr(request.state, "request_id", None),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -155,6 +164,7 @@ def build_app(
|
||||
message=f"request body validation failed on {first_loc}",
|
||||
retryable=False, suggested_fix=suggestion,
|
||||
details={"errors": safe_errs},
|
||||
request_id=getattr(request.state, "request_id", None),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -166,6 +176,7 @@ def build_app(
|
||||
type_="internal_error", code="UNHANDLED_EXCEPTION",
|
||||
message=f"{type(exc).__name__}: {str(exc)[:300]}",
|
||||
retryable=True,
|
||||
request_id=getattr(request.state, "request_id", None),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -179,6 +190,79 @@ def build_app(
|
||||
"data_timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
@app.get("/health/ready", tags=["system"])
|
||||
async def health_ready():
|
||||
"""Readiness check: ping ogni client exchange cached.
|
||||
|
||||
- Itera ``app.state.registry._clients`` (se presente).
|
||||
- Per ogni client prova ``health()`` (preferito) o ``is_testnet()``.
|
||||
In assenza di metodo, marca con ``note: no probe method``.
|
||||
- Timeout di 2s per client tramite ``asyncio.wait_for``.
|
||||
- Stato globale: ``ready`` se tutti ok, ``degraded`` se almeno
|
||||
uno fallisce, ``not_ready`` se registry vuoto.
|
||||
- HTTP 200 di default; con ``READY_FAILS_ON_DEGRADED=true`` ritorna
|
||||
503 quando lo stato non è ``ready`` (utile per probe k8s).
|
||||
"""
|
||||
registry = getattr(app.state, "registry", None)
|
||||
clients_status: list[dict[str, Any]] = []
|
||||
if registry is not None:
|
||||
for (exchange, env), client in registry._clients.items():
|
||||
t0 = time.perf_counter()
|
||||
ping = (
|
||||
getattr(client, "health", None)
|
||||
or getattr(client, "is_testnet", None)
|
||||
)
|
||||
if ping is None:
|
||||
clients_status.append({
|
||||
"exchange": exchange,
|
||||
"env": env,
|
||||
"healthy": True,
|
||||
"note": "no probe method",
|
||||
})
|
||||
continue
|
||||
try:
|
||||
res = ping()
|
||||
if asyncio.iscoroutine(res):
|
||||
await asyncio.wait_for(res, timeout=2.0)
|
||||
dur = (time.perf_counter() - t0) * 1000
|
||||
clients_status.append({
|
||||
"exchange": exchange,
|
||||
"env": env,
|
||||
"healthy": True,
|
||||
"duration_ms": round(dur, 2),
|
||||
})
|
||||
except Exception as e:
|
||||
clients_status.append({
|
||||
"exchange": exchange,
|
||||
"env": env,
|
||||
"healthy": False,
|
||||
"error": f"{type(e).__name__}: {str(e)[:200]}",
|
||||
})
|
||||
|
||||
if not clients_status:
|
||||
status_label = "not_ready"
|
||||
elif all(c["healthy"] for c in clients_status):
|
||||
status_label = "ready"
|
||||
else:
|
||||
status_label = "degraded"
|
||||
|
||||
fail_on_degraded = os.environ.get(
|
||||
"READY_FAILS_ON_DEGRADED", "false"
|
||||
).lower() in ("1", "true", "yes")
|
||||
http_code = 200
|
||||
if fail_on_degraded and status_label != "ready":
|
||||
http_code = 503
|
||||
|
||||
body = {
|
||||
"status": status_label,
|
||||
"name": title,
|
||||
"version": version,
|
||||
"uptime_seconds": int(time.time() - app.state.boot_at),
|
||||
"data_timestamp": datetime.now(UTC).isoformat(),
|
||||
"clients": clients_status,
|
||||
}
|
||||
return JSONResponse(status_code=http_code, content=body)
|
||||
|
||||
def _custom_openapi() -> dict[str, Any]:
|
||||
if app.openapi_schema:
|
||||
return app.openapi_schema
|
||||
|
||||
Reference in New Issue
Block a user