Files
cleveragents-core/features/steps/server_http_client_steps.py
T
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

514 lines
18 KiB
Python

"""Step definitions for server HTTP client feature tests.
Covers construction, health check, version negotiation, pagination,
error mapping, retry logic, auth redaction, and the settings factory.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
import httpx
from behave import given, then, when
from behave.runner import Context
from cleveragents.a2a.errors import A2aNotAvailableError
from cleveragents.client.exceptions import (
ServerConnectionError,
ServerTimeoutError,
ServerVersionMismatchError,
)
from cleveragents.client.http_client import (
PageResult,
ServerHttpClient,
_backoff_delay,
_redact_headers,
create_client_from_settings,
)
# ---------------------------------------------------------------------------
# Helpers — mock httpx.request
# ---------------------------------------------------------------------------
def _mock_response(
status: int = 200,
body: dict[str, Any] | list[Any] | None = None,
headers: dict[str, str] | None = None,
) -> httpx.Response:
"""Build a fake httpx.Response."""
resp = httpx.Response(
status_code=status,
json=body,
headers=headers or {},
request=httpx.Request("GET", "http://mock"),
)
return resp
# ---------------------------------------------------------------------------
# Construction and configuration
# ---------------------------------------------------------------------------
@given("a ServerHttpClient with default settings")
def step_client_defaults(context: Context) -> None:
context.http_client = ServerHttpClient()
context.call_error = None
@given('a ServerHttpClient with base_url "{url}" and token "{token}"')
def step_client_custom(context: Context, url: str, token: str) -> None:
context.http_client = ServerHttpClient(base_url=url, api_token=token)
context.call_error = None
@given("a ServerHttpClient with tls_verify disabled")
def step_client_no_tls(context: Context) -> None:
context.http_client = ServerHttpClient(tls_verify=False)
context.call_error = None
@then('the client base_url should be "{expected}"')
def step_check_base_url(context: Context, expected: str) -> None:
assert context.http_client.base_url == expected
@then("the client tls_verify should be true")
def step_check_tls_true(context: Context) -> None:
assert context.http_client.tls_verify is True
@then("the client tls_verify should be false")
def step_check_tls_false(context: Context) -> None:
assert context.http_client.tls_verify is False
@then("the client timeout should be {expected:g}")
def step_check_timeout(context: Context, expected: float) -> None:
assert context.http_client.timeout == expected
# ---------------------------------------------------------------------------
# Health check
# ---------------------------------------------------------------------------
@given("a ServerHttpClient with a mock healthy server")
def step_mock_healthy(context: Context) -> None:
context.http_client = ServerHttpClient()
context._mock_responses = [_mock_response(200, {"status": "healthy"})]
context.call_error = None
@given("a ServerHttpClient with an unreachable server")
def step_mock_unreachable(context: Context) -> None:
context.http_client = ServerHttpClient(base_url="http://192.0.2.1:1", max_retries=1)
context._mock_connect_error = True
context.call_error = None
@when("I call health_check on the http client")
def step_health_check(context: Context) -> None:
if getattr(context, "_mock_connect_error", False):
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
mock_req.side_effect = httpx.ConnectError("refused")
context.health_result = context.http_client.health_check()
elif hasattr(context, "_mock_responses"):
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
mock_req.return_value = context._mock_responses[0]
context.health_result = context.http_client.health_check()
else:
context.health_result = context.http_client.health_check()
@then("the health check result should be true")
def step_health_true(context: Context) -> None:
assert context.health_result is True
@then("the health check result should be false")
def step_health_false(context: Context) -> None:
assert context.health_result is False
# ---------------------------------------------------------------------------
# Version retrieval
# ---------------------------------------------------------------------------
@given('a ServerHttpClient with a mock version server returning "{version}"')
def step_mock_version_server(context: Context, version: str) -> None:
context.http_client = ServerHttpClient()
context._mock_responses = [_mock_response(200, {"version": version})]
context.call_error = None
@when("I call get_version on the http client")
def step_get_version(context: Context) -> None:
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
if hasattr(context, "_mock_fail_then_succeed"):
mock_req.side_effect = context._mock_fail_then_succeed
else:
mock_req.return_value = context._mock_responses[0]
context.version_result = context.http_client.get_version()
@then('the version result should be "{expected}"')
def step_check_version(context: Context, expected: str) -> None:
assert context.version_result == expected
# ---------------------------------------------------------------------------
# Version negotiation
# ---------------------------------------------------------------------------
@given('a ServerHttpClient with a mock negotiate server returning "{version}"')
def step_mock_negotiate(context: Context, version: str) -> None:
context.http_client = ServerHttpClient()
body: dict[str, Any] = {"negotiated_version": version} if version else {}
context._mock_responses = [_mock_response(200, body)]
context.call_error = None
@when("I call negotiate_version on the http client")
def step_negotiate(context: Context) -> None:
try:
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
mock_req.return_value = context._mock_responses[0]
context.negotiated_version = context.http_client.negotiate_version()
except ServerVersionMismatchError as exc:
context.call_error = exc
@then('the http client negotiated version should be "{expected}"')
def step_check_negotiated(context: Context, expected: str) -> None:
assert context.negotiated_version == expected
@then("a ServerVersionMismatchError should be raised")
def step_version_mismatch_raised(context: Context) -> None:
assert isinstance(context.call_error, ServerVersionMismatchError)
@given("a ServerHttpClient with a mock negotiate server returning empty")
def step_mock_negotiate_empty(context: Context) -> None:
context.http_client = ServerHttpClient()
context._mock_responses = [_mock_response(200, {})]
context.call_error = None
@when("I call negotiate_version on the http client expecting error")
def step_negotiate_expecting_error(context: Context) -> None:
try:
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
mock_req.return_value = context._mock_responses[0]
context.http_client.negotiate_version()
except ServerVersionMismatchError as exc:
context.call_error = exc
@then("a ServerVersionMismatchError should be raised from http client")
def step_version_mismatch_raised_http(context: Context) -> None:
assert isinstance(context.call_error, ServerVersionMismatchError)
# ---------------------------------------------------------------------------
# Pagination
# ---------------------------------------------------------------------------
@given("a ServerHttpClient with a mock paginated endpoint returning {n:d} items")
def step_mock_paginated(context: Context, n: int) -> None:
context.http_client = ServerHttpClient()
items = [{"id": str(i)} for i in range(n)]
context._mock_responses = [
_mock_response(200, {"items": items, "total": n, "has_next": False})
]
context.call_error = None
@given("a ServerHttpClient with a mock list endpoint returning {n:d} items")
def step_mock_list(context: Context, n: int) -> None:
context.http_client = ServerHttpClient()
items = [{"id": str(i)} for i in range(n)]
context._mock_responses = [_mock_response(200, items)]
context.call_error = None
@when("I call list_endpoint with page {page:d} per_page {per_page:d}")
def step_list_endpoint(context: Context, page: int, per_page: int) -> None:
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
mock_req.return_value = context._mock_responses[0]
context.page_result = context.http_client.list_endpoint(
"/items", page=page, per_page=per_page
)
@then("the page result should have {n:d} items")
def step_check_page_items(context: Context, n: int) -> None:
assert len(context.page_result.items) == n
@then("the page result should have total {n:d}")
def step_check_page_total(context: Context, n: int) -> None:
assert context.page_result.total == n
@then("the page result has_next should be false")
def step_check_page_no_next(context: Context) -> None:
assert context.page_result.has_next is False
@then("the page result has_next should be true")
def step_check_page_has_next(context: Context) -> None:
assert context.page_result.has_next is True
# ---------------------------------------------------------------------------
# Error mapping
# ---------------------------------------------------------------------------
@given("a ServerHttpClient with a mock server returning {status:d}")
def step_mock_error_server(context: Context, status: int) -> None:
context.http_client = ServerHttpClient(max_retries=1)
context._mock_responses = [_mock_response(status, {"error": "test"})]
context.call_error = None
@when("I call get_version and capture the error")
def step_get_version_capture(context: Context) -> None:
try:
with patch("cleveragents.client.http_client.httpx.request") as mock_req:
mock_req.return_value = context._mock_responses[0]
context.http_client.get_version()
except Exception as exc:
context.call_error = exc
@then("an A2aNotAvailableError should be raised from the http client")
def step_acp_not_available(context: Context) -> None:
assert isinstance(context.call_error, A2aNotAvailableError), (
f"Expected A2aNotAvailableError, got {type(context.call_error)}"
)
@then("a ServerConnectionError should be raised from the http client")
def step_connection_error(context: Context) -> None:
assert isinstance(context.call_error, ServerConnectionError), (
f"Expected ServerConnectionError, got {type(context.call_error)}"
)
# ---------------------------------------------------------------------------
# Retry logic
# ---------------------------------------------------------------------------
@given("a ServerHttpClient with a mock server that fails twice then succeeds")
def step_mock_retry(context: Context) -> None:
context.http_client = ServerHttpClient(max_retries=3, backoff_base=0.001)
call_count = 0
def _side_effect(*args: Any, **kwargs: Any) -> httpx.Response:
nonlocal call_count
call_count += 1
if call_count < 3:
return _mock_response(500, {"error": "temp"})
return _mock_response(200, {"version": "1.0.0"})
context._mock_fail_then_succeed = _side_effect
context.call_error = None
# ---------------------------------------------------------------------------
# Auth header redaction
# ---------------------------------------------------------------------------
@given("request headers with an Authorization bearer token")
def step_headers_with_auth(context: Context) -> None:
context.raw_headers = {
"Authorization": "Bearer tok_secret123abcdefghij",
"Accept": "application/json",
}
@when("I redact the headers")
def step_redact(context: Context) -> None:
context.redacted_headers = _redact_headers(context.raw_headers)
@then('the Authorization value should be "***REDACTED***"')
def step_auth_redacted(context: Context) -> None:
assert context.redacted_headers["Authorization"] == "***REDACTED***"
@then("the Accept header should not be redacted")
def step_accept_not_redacted(context: Context) -> None:
assert context.redacted_headers["Accept"] == "application/json"
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
@when("I call close on the http client")
def step_close(context: Context) -> None:
try:
context.http_client.close()
context.call_error = None
except Exception as exc:
context.call_error = exc
@then("no error should be raised from the http client")
def step_no_error(context: Context) -> None:
assert context.call_error is None
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
@when("I call create_client_from_settings without a configured URL")
def step_factory_no_url(context: Context) -> None:
try:
with patch("cleveragents.config.settings.Settings.get_settings") as mock_gs:
mock_settings = MagicMock()
mock_settings.server_base_url = None
mock_gs.return_value = mock_settings
create_client_from_settings()
except ValueError as exc:
context.call_error = exc
@then("a ValueError should be raised with URL configuration message from factory")
def step_value_error_url(context: Context) -> None:
assert isinstance(context.call_error, ValueError)
assert "server_base_url" in str(context.call_error)
@when("I call create_client_from_settings with a configured URL")
def step_factory_with_url(context: Context) -> None:
with patch("cleveragents.config.settings.Settings.get_settings") as mock_gs:
mock_settings = MagicMock()
mock_settings.server_base_url = "https://test.example.com"
mock_settings.server_api_token = "tok_testabc123defghijk"
mock_settings.server_tls_verify = True
mock_settings.server_request_timeout = 15.0
mock_gs.return_value = mock_settings
context.factory_client = create_client_from_settings()
@then("a ServerHttpClient should be returned")
def step_factory_returns_client(context: Context) -> None:
assert isinstance(context.factory_client, ServerHttpClient)
# ---------------------------------------------------------------------------
# Exception attributes
# ---------------------------------------------------------------------------
@given('a ServerConnectionError with url "{url}" and cause')
def step_conn_err_attrs(context: Context, url: str) -> None:
context.test_error = ServerConnectionError(
message="test", url=url, cause=RuntimeError("boom")
)
@then('the error url attribute should be "{expected}"')
def step_check_err_url(context: Context, expected: str) -> None:
assert context.test_error.url == expected
@then("the error cause attribute should not be None")
def step_check_err_cause(context: Context) -> None:
assert context.test_error.cause is not None
@given("a ServerTimeoutError with {seconds:g} seconds")
def step_timeout_err(context: Context, seconds: float) -> None:
context.test_error = ServerTimeoutError(message="timeout", timeout_seconds=seconds)
@then("the error timeout_seconds attribute should be {expected:g}")
def step_check_timeout_secs(context: Context, expected: float) -> None:
assert context.test_error.timeout_seconds == expected
@given('a ServerVersionMismatchError with client "{cv}" and server versions')
def step_version_err(context: Context, cv: str) -> None:
context.test_error = ServerVersionMismatchError(
message="mismatch",
client_version=cv,
server_versions=["1.0"],
)
@then('the error client_version attribute should be "{expected}"')
def step_check_client_version(context: Context, expected: str) -> None:
assert context.test_error.client_version == expected
@then('the error server_versions attribute should contain "{expected}"')
def step_check_server_versions(context: Context, expected: str) -> None:
assert expected in context.test_error.server_versions
# ---------------------------------------------------------------------------
# Backoff helper
# ---------------------------------------------------------------------------
@when(
"I compute backoff delay for attempt {attempt:d} with base {base:g} and max {maximum:g}"
)
def step_compute_backoff(
context: Context, attempt: int, base: float, maximum: float
) -> None:
context.backoff_delay = _backoff_delay(attempt, base, maximum)
@then("the delay should be {expected:g}")
def step_check_delay(context: Context, expected: float) -> None:
assert context.backoff_delay == expected
# ---------------------------------------------------------------------------
# PageResult attributes
# ---------------------------------------------------------------------------
@given(
"a PageResult with {n:d} items page {page:d} per_page {pp:d} total {total:d} has_next true"
)
def step_page_result_attrs(
context: Context, n: int, page: int, pp: int, total: int
) -> None:
items = [{"id": str(i)} for i in range(n)]
context.page_result = PageResult(
items=items, page=page, per_page=pp, total=total, has_next=True
)
@then("the page result page should be {expected:d}")
def step_check_page_num(context: Context, expected: int) -> None:
assert context.page_result.page == expected
@then("the page result per_page should be {expected:d}")
def step_check_per_page(context: Context, expected: int) -> None:
assert context.page_result.per_page == expected
@then("the page result total should be {expected:d}")
def step_check_total(context: Context, expected: int) -> None:
assert context.page_result.total == expected