Files
kami_spider_monorepo/tests/test_http_client.py
danial aebc83edc9 feat(clients): 添加第三方 API 客户端基础库及示例实现
- 新增 BaseAPIClient 抽象基类,提供连接池管理、自动重试、超时控制、日志记录和链路追踪功能
- 实现基于 httpx 的 HTTPClient,支持异步请求、JSON 和表单数据、连接池优化
- 提供示例客户端 ExampleAPIClient,展示如何继承自定义第三方服务客户端
- 编写详细的第三方 API 客户端使用指南文档,包含模块划分、核心组件、快速开始及最佳实践
- 集成 OpenTelemetry 追踪,实现请求全链路追踪和错误记录
- 支持 FastAPI 依赖注入和应用生命周期管理客户端实例
- 完善自动重试策略,包含指数退避和可重试异常分类
- 实现敏感请求头自动脱敏,防止日志泄露敏感数据
- 增加客户端健康检查接口,验证服务可用性
- 编写完整单元测试,覆盖客户端初始化、请求发送、重试逻辑及上下文管理器用法
2025-11-01 15:00:18 +08:00

216 lines
7.1 KiB
Python

"""
Tests for HTTP client.
"""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import httpx
from core.clients.http_client import HTTPClient
@pytest.fixture
def mock_httpx_response():
"""Create a mock httpx response."""
response = MagicMock(spec=httpx.Response)
response.status_code = 200
response.json.return_value = {"success": True, "data": {"id": 1}}
response.elapsed.total_seconds.return_value = 0.123
response.raise_for_status = MagicMock()
return response
@pytest.mark.asyncio
async def test_http_client_initialization():
"""Test HTTP client initialization."""
client = HTTPClient(
base_url="https://api.example.com",
timeout=30.0,
max_retries=3,
headers={"Authorization": "Bearer test_token"}
)
assert client.base_url == "https://api.example.com"
assert client.timeout == 30.0
assert client.max_retries == 3
assert client.default_headers["Authorization"] == "Bearer test_token"
await client.close()
@pytest.mark.asyncio
async def test_http_client_get_request(mock_httpx_response):
"""Test HTTP GET request."""
client = HTTPClient(base_url="https://api.example.com")
with patch.object(client, '_get_client') as mock_get_client:
mock_async_client = AsyncMock()
mock_async_client.request = AsyncMock(return_value=mock_httpx_response)
mock_get_client.return_value = mock_async_client
response = await client.get("/users/1", params={"include": "profile"})
assert response.status_code == 200
data = response.json()
assert data["success"] is True
# Verify the request was made correctly
mock_async_client.request.assert_called_once()
call_args = mock_async_client.request.call_args
assert call_args.kwargs["method"] == "GET"
assert "users/1" in call_args.kwargs["url"]
assert call_args.kwargs["params"] == {"include": "profile"}
await client.close()
@pytest.mark.asyncio
async def test_http_client_post_request(mock_httpx_response):
"""Test HTTP POST request."""
client = HTTPClient(base_url="https://api.example.com")
with patch.object(client, '_get_client') as mock_get_client:
mock_async_client = AsyncMock()
mock_async_client.request = AsyncMock(return_value=mock_httpx_response)
mock_get_client.return_value = mock_async_client
payload = {"name": "John", "email": "john@example.com"}
response = await client.post("/users", json=payload)
assert response.status_code == 200
# Verify the request
mock_async_client.request.assert_called_once()
call_args = mock_async_client.request.call_args
assert call_args.kwargs["method"] == "POST"
assert call_args.kwargs["json"] == payload
await client.close()
@pytest.mark.asyncio
async def test_http_client_context_manager():
"""Test HTTP client as context manager."""
async with HTTPClient(base_url="https://api.example.com") as client:
assert client is not None
assert client._client is None # Not initialized until first use
# Client should be closed after context
assert client._client is None
@pytest.mark.asyncio
async def test_http_client_retry_on_timeout():
"""Test automatic retry on timeout."""
client = HTTPClient(
base_url="https://api.example.com",
max_retries=3,
retry_delay=0.01 # Fast retry for testing
)
with patch.object(client, '_get_client') as mock_get_client:
mock_async_client = AsyncMock()
# Simulate 2 timeouts, then success
mock_async_client.request = AsyncMock(
side_effect=[
httpx.TimeoutException("Timeout 1"),
httpx.TimeoutException("Timeout 2"),
MagicMock(
status_code=200,
json=MagicMock(return_value={"success": True}),
elapsed=MagicMock(total_seconds=MagicMock(return_value=0.1)),
raise_for_status=MagicMock()
)
]
)
mock_get_client.return_value = mock_async_client
response = await client.get("/users")
assert response.status_code == 200
assert mock_async_client.request.call_count == 3 # 2 retries + 1 success
await client.close()
@pytest.mark.asyncio
async def test_http_client_retry_exhausted():
"""Test retry exhaustion."""
client = HTTPClient(
base_url="https://api.example.com",
max_retries=2,
retry_delay=0.01
)
with patch.object(client, '_get_client') as mock_get_client:
mock_async_client = AsyncMock()
mock_async_client.request = AsyncMock(
side_effect=httpx.TimeoutException("Persistent timeout")
)
mock_get_client.return_value = mock_async_client
with pytest.raises(httpx.TimeoutException):
await client.get("/users")
assert mock_async_client.request.call_count == 2 # max_retries
await client.close()
@pytest.mark.asyncio
async def test_http_client_custom_timeout():
"""Test custom timeout for individual request."""
client = HTTPClient(base_url="https://api.example.com", timeout=30.0)
with patch.object(client, '_get_client') as mock_get_client:
mock_async_client = AsyncMock()
mock_async_client.request = AsyncMock(
return_value=MagicMock(
status_code=200,
json=MagicMock(return_value={}),
elapsed=MagicMock(total_seconds=MagicMock(return_value=0.1)),
raise_for_status=MagicMock()
)
)
mock_get_client.return_value = mock_async_client
await client.get("/users", timeout=10.0)
# Verify custom timeout was used
call_args = mock_async_client.request.call_args
assert call_args.kwargs["timeout"] == 10.0
await client.close()
@pytest.mark.asyncio
async def test_http_client_headers_merge():
"""Test header merging."""
client = HTTPClient(
base_url="https://api.example.com",
headers={"Authorization": "Bearer token", "User-Agent": "TestClient/1.0"}
)
with patch.object(client, '_get_client') as mock_get_client:
mock_async_client = AsyncMock()
mock_async_client.request = AsyncMock(
return_value=MagicMock(
status_code=200,
json=MagicMock(return_value={}),
elapsed=MagicMock(total_seconds=MagicMock(return_value=0.1)),
raise_for_status=MagicMock()
)
)
mock_get_client.return_value = mock_async_client
await client.get("/users", headers={"X-Custom": "value"})
# Verify headers were merged
call_args = mock_async_client.request.call_args
headers = call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer token"
assert headers["User-Agent"] == "TestClient/1.0"
assert headers["X-Custom"] == "value"
await client.close()