feat(cerbero): HTTP client with bearer + bot-tag + retry

Client minimale verso Cerbero MCP unified server con header
Authorization Bearer e X-Bot-Tag su ogni richiesta. Retry esponenziale
solo su ConnectionError (non su 4xx). 4xx/5xx -> RuntimeError.

Switch da httpx a requests per allineamento con libreria mock
`responses` (httpx richiederebbe respx). httpx resta in deps per usi
futuri. Aggiunto types-requests ai dev deps per mypy strict.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 19:25:00 +02:00
parent b61bbaf13c
commit 14b1b84a47
5 changed files with 115 additions and 0 deletions
+2
View File
@@ -16,6 +16,7 @@ dependencies = [
"anthropic>=0.39",
"openai>=1.55",
"httpx>=0.28",
"requests>=2.32",
"tenacity>=9.0",
"pyyaml>=6.0",
"streamlit>=1.40",
@@ -31,6 +32,7 @@ dev = [
"responses>=0.25",
"ruff>=0.7",
"mypy>=1.13",
"types-requests>=2.32",
]
[build-system]
View File
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from types import TracebackType
from typing import Any
import requests
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
class CerberoClient:
"""Client HTTP minimale verso Cerbero MCP unified server."""
def __init__(
self,
base_url: str,
token: str,
bot_tag: str,
timeout_seconds: float = 10.0,
) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
self.bot_tag = bot_tag
self.timeout_seconds = timeout_seconds
self._session = requests.Session()
self._session.headers.update(
{
"Authorization": f"Bearer {token}",
"X-Bot-Tag": bot_tag,
"Content-Type": "application/json",
}
)
def close(self) -> None:
self._session.close()
def __enter__(self) -> CerberoClient:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self.close()
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=0.5, min=0.5, max=4.0),
retry=retry_if_exception_type(requests.ConnectionError),
reraise=True,
)
def call_tool(self, exchange: str, tool: str, args: dict[str, Any]) -> Any:
url = f"{self.base_url}/mcp-{exchange}/tools/{tool}"
resp = self._session.post(url, json=args, timeout=self.timeout_seconds)
if resp.status_code >= 400:
raise RuntimeError(
f"Cerbero {exchange}/{tool} returned {resp.status_code}: {resp.text}"
)
return resp.json()
+37
View File
@@ -0,0 +1,37 @@
import pytest
import responses
from multi_swarm.cerbero.client import CerberoClient
@responses.activate
def test_call_tool_passes_bearer_and_bot_tag() -> None:
responses.add(
responses.POST,
"http://test:9000/mcp-deribit/tools/get_iv_rank",
json={"iv_rank": 0.42},
status=200,
)
client = CerberoClient(
base_url="http://test:9000", token="tok-xyz", bot_tag="swarm-poc-phase1"
)
result = client.call_tool("deribit", "get_iv_rank", {"symbol": "BTC-PERPETUAL"})
assert result == {"iv_rank": 0.42}
req = responses.calls[0].request
assert req.headers["Authorization"] == "Bearer tok-xyz"
assert req.headers["X-Bot-Tag"] == "swarm-poc-phase1"
@responses.activate
def test_call_tool_raises_on_error() -> None:
responses.add(
responses.POST,
"http://test:9000/mcp-deribit/tools/get_iv_rank",
json={"error": "bad"},
status=400,
)
client = CerberoClient(
base_url="http://test:9000", token="tok-xyz", bot_tag="swarm-poc-phase1"
)
with pytest.raises(RuntimeError):
client.call_tool("deribit", "get_iv_rank", {})
Generated
+16
View File
@@ -898,6 +898,7 @@ dependencies = [
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "scipy" },
{ name = "sexpdata" },
{ name = "sqlmodel" },
@@ -913,6 +914,7 @@ dev = [
{ name = "pytest-mock" },
{ name = "responses" },
{ name = "ruff" },
{ name = "types-requests" },
]
[package.metadata]
@@ -928,6 +930,7 @@ requires-dist = [
{ name = "pydantic", specifier = ">=2.9" },
{ name = "pydantic-settings", specifier = ">=2.6" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "requests", specifier = ">=2.32" },
{ name = "scipy", specifier = ">=1.14" },
{ name = "sexpdata", specifier = ">=1.0.2" },
{ name = "sqlmodel", specifier = ">=0.0.22" },
@@ -943,6 +946,7 @@ dev = [
{ name = "pytest-mock", specifier = ">=3.14" },
{ name = "responses", specifier = ">=0.25" },
{ name = "ruff", specifier = ">=0.7" },
{ name = "types-requests", specifier = ">=2.32" },
]
[[package]]
@@ -2048,6 +2052,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
]
[[package]]
name = "types-requests"
version = "2.33.0.20260508"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c7/6b/eb226bdd61a982c9a03e02c657fb4ab001733506e6423906ac142331f2e3/types_requests-2.33.0.20260508.tar.gz", hash = "sha256:81b2ae5f0d20967714a6aa5ef9284c05570d7cb06b7de8f2a77b918b63ddd411", size = 23991, upload-time = "2026-05-08T04:50:56.818Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/96/080db0afdf2c5cc5fe512b41354e8d114fe8f65e9510c56ff8dfd40216ce/types_requests-2.33.0.20260508-py3-none-any.whl", hash = "sha256:fa01459cca184229713df03709db46a905325906d27e042cd4fd7ea3d15d3400", size = 20722, upload-time = "2026-05-08T04:50:55.548Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"