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:
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(monkeypatch):
|
||||
from tests.unit.test_settings import _minimal_env
|
||||
for k, v in _minimal_env().items():
|
||||
monkeypatch.setenv(k, v)
|
||||
from cerbero_mcp.__main__ import _make_app
|
||||
from cerbero_mcp.settings import Settings
|
||||
return _make_app(Settings())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def request_log_caplog(caplog):
|
||||
"""Caplog per logger mcp.request: aggiunge l'handler caplog direttamente
|
||||
al logger, bypassando ``propagate=False`` settato da common/logging.py.
|
||||
"""
|
||||
lg = logging.getLogger("mcp.request")
|
||||
lg.addHandler(caplog.handler)
|
||||
lg.setLevel(logging.INFO)
|
||||
caplog.set_level(logging.INFO, logger="mcp.request")
|
||||
try:
|
||||
yield caplog
|
||||
finally:
|
||||
lg.removeHandler(caplog.handler)
|
||||
|
||||
|
||||
def _request_records(caplog):
|
||||
return [rec for rec in caplog.records if rec.name == "mcp.request"]
|
||||
|
||||
|
||||
def test_request_log_emits_for_health(app, request_log_caplog):
|
||||
c = TestClient(app)
|
||||
r = c.get("/health")
|
||||
assert r.status_code == 200
|
||||
records = _request_records(request_log_caplog)
|
||||
assert any(getattr(rec, "path", None) == "/health" for rec in records)
|
||||
|
||||
|
||||
def test_request_log_includes_request_id(app, request_log_caplog):
|
||||
c = TestClient(app)
|
||||
c.get("/health")
|
||||
records = _request_records(request_log_caplog)
|
||||
assert records, "expected at least one mcp.request record"
|
||||
for rec in records:
|
||||
rid = getattr(rec, "request_id", None)
|
||||
assert rid and isinstance(rid, str) and len(rid) >= 16
|
||||
|
||||
|
||||
def test_request_log_includes_method_status_duration(app, request_log_caplog):
|
||||
c = TestClient(app)
|
||||
c.get("/health")
|
||||
records = _request_records(request_log_caplog)
|
||||
rec = next(rec for rec in records if getattr(rec, "path", None) == "/health")
|
||||
assert getattr(rec, "method", None) == "GET"
|
||||
assert getattr(rec, "status_code", None) == 200
|
||||
assert isinstance(getattr(rec, "duration_ms", None), int | float)
|
||||
|
||||
|
||||
def test_request_log_includes_actor_and_bot_tag_on_protected(
|
||||
app, request_log_caplog
|
||||
):
|
||||
"""Su path autenticato actor/bot_tag/exchange/tool sono propagati."""
|
||||
c = TestClient(app)
|
||||
c.post(
|
||||
"/mcp-deribit/tools/is_testnet",
|
||||
headers={
|
||||
"Authorization": "Bearer t_test_123",
|
||||
"X-Bot-Tag": "scanner-x",
|
||||
},
|
||||
json={},
|
||||
)
|
||||
records = _request_records(request_log_caplog)
|
||||
rec = next(
|
||||
rec
|
||||
for rec in records
|
||||
if getattr(rec, "path", None) == "/mcp-deribit/tools/is_testnet"
|
||||
)
|
||||
assert getattr(rec, "actor", None) == "testnet"
|
||||
assert getattr(rec, "bot_tag", None) == "scanner-x"
|
||||
assert getattr(rec, "exchange", None) == "deribit"
|
||||
assert getattr(rec, "tool", None) == "is_testnet"
|
||||
|
||||
|
||||
def test_request_log_unauthorized_does_not_have_actor(
|
||||
app, request_log_caplog
|
||||
):
|
||||
"""Senza bearer, request log emette comunque ma senza actor/bot_tag."""
|
||||
c = TestClient(app)
|
||||
c.post("/mcp-deribit/tools/is_testnet", json={})
|
||||
records = _request_records(request_log_caplog)
|
||||
rec = next(
|
||||
rec
|
||||
for rec in records
|
||||
if getattr(rec, "path", None) == "/mcp-deribit/tools/is_testnet"
|
||||
)
|
||||
assert getattr(rec, "status_code", None) == 401
|
||||
assert getattr(rec, "actor", None) is None
|
||||
assert getattr(rec, "exchange", None) == "deribit"
|
||||
|
||||
|
||||
def test_request_id_in_state_for_handlers(app):
|
||||
"""Verifica che request.state.request_id sia disponibile a handler."""
|
||||
@app.get("/__test_state")
|
||||
def _state_handler(request: StarletteRequest) -> dict:
|
||||
return {"rid": request.state.request_id}
|
||||
|
||||
c = TestClient(app)
|
||||
r = c.get(
|
||||
"/__test_state",
|
||||
headers={"Authorization": "Bearer t_test_123", "X-Bot-Tag": "x"},
|
||||
)
|
||||
assert r.status_code == 200, f"got {r.status_code}: {r.text[:500]}"
|
||||
assert r.json()["rid"]
|
||||
@@ -60,3 +60,123 @@ def test_x_duration_ms_header(app):
|
||||
c = TestClient(app)
|
||||
r = c.get("/health")
|
||||
assert "X-Duration-Ms" in r.headers
|
||||
|
||||
|
||||
def test_health_ready_empty_registry(app):
|
||||
"""Senza registry il readiness ritorna not_ready ma HTTP 200."""
|
||||
c = TestClient(app)
|
||||
r = c.get("/health/ready")
|
||||
assert r.status_code == 200
|
||||
j = r.json()
|
||||
assert j["status"] == "not_ready"
|
||||
assert j["clients"] == []
|
||||
assert j["version"] == "2.0.0"
|
||||
|
||||
|
||||
def test_health_ready_all_healthy(app):
|
||||
"""Registry con stub client healthy → status=ready."""
|
||||
from cerbero_mcp.client_registry import ClientRegistry
|
||||
|
||||
class _StubOk:
|
||||
async def health(self):
|
||||
return {"status": "ok"}
|
||||
|
||||
async def _builder(exchange, env): # pragma: no cover - non chiamato
|
||||
return _StubOk()
|
||||
|
||||
reg = ClientRegistry(builder=_builder)
|
||||
reg._clients[("deribit", "testnet")] = _StubOk()
|
||||
reg._clients[("bybit", "mainnet")] = _StubOk()
|
||||
app.state.registry = reg
|
||||
|
||||
c = TestClient(app)
|
||||
r = c.get("/health/ready")
|
||||
assert r.status_code == 200
|
||||
j = r.json()
|
||||
assert j["status"] == "ready"
|
||||
assert len(j["clients"]) == 2
|
||||
for entry in j["clients"]:
|
||||
assert entry["healthy"] is True
|
||||
assert "duration_ms" in entry
|
||||
|
||||
|
||||
def test_health_ready_degraded_on_error(app):
|
||||
"""Registry con almeno un client che fa raise → status=degraded."""
|
||||
from cerbero_mcp.client_registry import ClientRegistry
|
||||
|
||||
class _StubOk:
|
||||
async def health(self):
|
||||
return {"status": "ok"}
|
||||
|
||||
class _StubFail:
|
||||
async def health(self):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
async def _builder(exchange, env): # pragma: no cover - non chiamato
|
||||
return _StubOk()
|
||||
|
||||
reg = ClientRegistry(builder=_builder)
|
||||
reg._clients[("deribit", "testnet")] = _StubOk()
|
||||
reg._clients[("bybit", "mainnet")] = _StubFail()
|
||||
app.state.registry = reg
|
||||
|
||||
c = TestClient(app)
|
||||
r = c.get("/health/ready")
|
||||
assert r.status_code == 200
|
||||
j = r.json()
|
||||
assert j["status"] == "degraded"
|
||||
fail = next(c for c in j["clients"] if c["exchange"] == "bybit")
|
||||
assert fail["healthy"] is False
|
||||
assert "RuntimeError" in fail["error"]
|
||||
|
||||
|
||||
def test_health_ready_503_when_fail_on_degraded(app, monkeypatch):
|
||||
"""READY_FAILS_ON_DEGRADED=true → HTTP 503 quando degraded."""
|
||||
from cerbero_mcp.client_registry import ClientRegistry
|
||||
|
||||
class _StubFail:
|
||||
async def health(self):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
async def _builder(exchange, env): # pragma: no cover - non chiamato
|
||||
return _StubFail()
|
||||
|
||||
reg = ClientRegistry(builder=_builder)
|
||||
reg._clients[("deribit", "testnet")] = _StubFail()
|
||||
app.state.registry = reg
|
||||
|
||||
monkeypatch.setenv("READY_FAILS_ON_DEGRADED", "true")
|
||||
c = TestClient(app)
|
||||
r = c.get("/health/ready")
|
||||
assert r.status_code == 503
|
||||
assert r.json()["status"] == "degraded"
|
||||
|
||||
|
||||
def test_health_ready_no_probe_method(app):
|
||||
"""Client senza health/is_testnet → marcato healthy con note."""
|
||||
from cerbero_mcp.client_registry import ClientRegistry
|
||||
|
||||
class _StubBare:
|
||||
pass
|
||||
|
||||
async def _builder(exchange, env): # pragma: no cover - non chiamato
|
||||
return _StubBare()
|
||||
|
||||
reg = ClientRegistry(builder=_builder)
|
||||
reg._clients[("foo", "testnet")] = _StubBare()
|
||||
app.state.registry = reg
|
||||
|
||||
c = TestClient(app)
|
||||
r = c.get("/health/ready")
|
||||
assert r.status_code == 200
|
||||
j = r.json()
|
||||
assert j["status"] == "ready"
|
||||
assert j["clients"][0]["note"] == "no probe method"
|
||||
|
||||
|
||||
def test_health_ready_in_whitelist_no_auth(app):
|
||||
"""/health/ready non richiede bearer."""
|
||||
c = TestClient(app)
|
||||
# Nessun Authorization header → 200 (whitelist)
|
||||
r = c.get("/health/ready")
|
||||
assert r.status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user