Files
cleveragents-core/robot/helper_server_http_client.py
freemo 5f7bba3e96 feat(client): add server http client
Implement ServerHttpClient with httpx for server communication including:
- Health check endpoint (GET /health)
- Version negotiation (GET /version, POST /version/negotiate)
- Pagination helpers for list endpoints
- Per-request timeout and retry policy with exponential backoff
- Request/response logging with auth header redaction
- TLS verification toggle with warning when disabled
- Server error responses mapped to domain errors (A2aNotAvailableError, etc.)
- Client-specific exceptions (ServerConnectionError, ServerTimeoutError,
  ServerVersionMismatchError)
- Settings fields: server_base_url, server_api_token, server_tls_verify,
  server_request_timeout
- Factory function create_client_from_settings wired to Settings
- httpx added to pyproject.toml dependencies
- Behave scenarios (23 scenarios, 72 steps)
- Robot Framework smoke tests
- ASV benchmark for connection overhead baseline
- Reference documentation at docs/reference/server_client_http.md

ISSUES CLOSED: #335
2026-03-24 20:28:19 +00:00

185 lines
5.5 KiB
Python

"""Helper script for server_http_client.robot integration tests.
Each subcommand is a self-contained check that prints a sentinel on success.
"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import patch
import httpx
# Ensure local source tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from cleveragents.client.exceptions import ( # noqa: E402
ServerConnectionError,
ServerTimeoutError,
ServerVersionMismatchError,
)
from cleveragents.client.http_client import ( # noqa: E402
ServerHttpClient,
_redact_headers,
)
def _mock_response(
status: int = 200,
body: dict | list | None = None,
headers: dict | None = None,
) -> httpx.Response:
return httpx.Response(
status_code=status,
json=body,
headers=headers or {},
request=httpx.Request("GET", "http://mock"),
)
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def client_create() -> None:
"""Verify client construction and properties."""
client = ServerHttpClient(
base_url="https://test.example.com",
api_token="tok_testkey1234567890abc",
tls_verify=True,
timeout=15.0,
)
assert client.base_url == "https://test.example.com"
assert client.tls_verify is True
assert client.timeout == 15.0
client.close()
print("server-http-client-create-ok")
def health_check() -> None:
"""Verify health check with mock."""
client = ServerHttpClient()
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
mock_req.return_value = _mock_response(200, {"status": "healthy"})
result = client.health_check()
assert result is True
print("server-http-client-health-ok")
def health_check_fail() -> None:
"""Verify health check failure."""
client = ServerHttpClient(max_retries=1)
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
mock_req.side_effect = httpx.ConnectError("refused")
result = client.health_check()
assert result is False
print("server-http-client-health-fail-ok")
def version() -> None:
"""Verify version retrieval."""
client = ServerHttpClient()
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
mock_req.return_value = _mock_response(200, {"version": "1.2.3"})
result = client.get_version()
assert result == "1.2.3"
print("server-http-client-version-ok")
def negotiate() -> None:
"""Verify version negotiation."""
client = ServerHttpClient()
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
mock_req.return_value = _mock_response(200, {"negotiated_version": "1.0"})
result = client.negotiate_version()
assert result == "1.0"
print("server-http-client-negotiate-ok")
def pagination() -> None:
"""Verify paginated list endpoint."""
client = ServerHttpClient()
items = [{"id": str(i)} for i in range(3)]
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
mock_req.return_value = _mock_response(
200, {"items": items, "total": 3, "has_next": False}
)
result = client.list_endpoint("/items", page=1, per_page=10)
assert len(result.items) == 3
assert result.total == 3
print("server-http-client-pagination-ok")
def error_503() -> None:
"""Verify 503 maps to A2aNotAvailableError."""
from cleveragents.a2a.errors import A2aNotAvailableError
client = ServerHttpClient(max_retries=1)
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
mock_req.return_value = _mock_response(503, {"error": "unavailable"})
try:
client.get_version()
print("FAIL: should have raised", file=sys.stderr)
sys.exit(1)
except A2aNotAvailableError:
pass
print("server-http-client-error-503-ok")
def redaction() -> None:
"""Verify auth header redaction."""
headers = {
"Authorization": "Bearer tok_secret123abcdefghij",
"Accept": "application/json",
}
redacted = _redact_headers(headers)
assert redacted["Authorization"] == "***REDACTED***"
assert redacted["Accept"] == "application/json"
print("server-http-client-redaction-ok")
def exceptions() -> None:
"""Verify exception attributes."""
err1 = ServerConnectionError("test", url="https://x.com", cause=RuntimeError("x"))
assert err1.url == "https://x.com"
assert err1.cause is not None
err2 = ServerTimeoutError("timeout", timeout_seconds=5.0)
assert err2.timeout_seconds == 5.0
err3 = ServerVersionMismatchError(
"mm", client_version="2.0", server_versions=["1.0"]
)
assert err3.client_version == "2.0"
assert "1.0" in err3.server_versions
print("server-http-client-exceptions-ok")
_COMMANDS = {
"client-create": client_create,
"health-check": health_check,
"health-check-fail": health_check_fail,
"version": version,
"negotiate": negotiate,
"pagination": pagination,
"error-503": error_503,
"redaction": redaction,
"exceptions": exceptions,
}
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
sys.exit(1)
_COMMANDS[sys.argv[1]]()
if __name__ == "__main__":
main()