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