forked from cleveragents/cleveragents-core
5f7bba3e96
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
514 lines
18 KiB
Python
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
|