Add HttpClientManager class for managing httpx.AsyncClient pools with FastAPI lifespan integration. Features include: - Default shared connection pool for general requests - Configurable max connections, keep-alive, and timeouts - Dedicated thread pool for blocking I/O operations - Graceful startup/shutdown lifecycle management - Per-upstream client isolation support (for future use) Includes comprehensive unit tests covering initialization, startup, shutdown, client retrieval, blocking operations, idempotency, and error handling.
195 lines
5.9 KiB
Python
195 lines
5.9 KiB
Python
"""Tests for HttpClientManager."""
|
|
import pytest
|
|
from unittest.mock import MagicMock, AsyncMock, patch
|
|
|
|
|
|
class TestHttpClientManager:
|
|
"""Tests for HTTP client pool management."""
|
|
|
|
@pytest.mark.unit
|
|
def test_manager_initializes_with_settings(self):
|
|
"""Manager should initialize with config settings."""
|
|
from app.http_client import HttpClientManager
|
|
from app.config import Settings
|
|
|
|
settings = Settings(
|
|
http_max_connections=50,
|
|
http_connect_timeout=15.0,
|
|
)
|
|
manager = HttpClientManager(settings)
|
|
|
|
assert manager.max_connections == 50
|
|
assert manager.connect_timeout == 15.0
|
|
assert manager._default_client is None # Not started yet
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_startup_creates_client(self):
|
|
"""Startup should create the default async client."""
|
|
from app.http_client import HttpClientManager
|
|
from app.config import Settings
|
|
|
|
settings = Settings()
|
|
manager = HttpClientManager(settings)
|
|
|
|
await manager.startup()
|
|
|
|
assert manager._default_client is not None
|
|
|
|
await manager.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_shutdown_closes_client(self):
|
|
"""Shutdown should close all clients gracefully."""
|
|
from app.http_client import HttpClientManager
|
|
from app.config import Settings
|
|
|
|
settings = Settings()
|
|
manager = HttpClientManager(settings)
|
|
|
|
await manager.startup()
|
|
client = manager._default_client
|
|
|
|
await manager.shutdown()
|
|
|
|
assert manager._default_client is None
|
|
assert client.is_closed
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_get_client_returns_default(self):
|
|
"""get_client() should return the default client."""
|
|
from app.http_client import HttpClientManager
|
|
from app.config import Settings
|
|
|
|
settings = Settings()
|
|
manager = HttpClientManager(settings)
|
|
await manager.startup()
|
|
|
|
client = manager.get_client()
|
|
|
|
assert client is manager._default_client
|
|
|
|
await manager.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_get_client_raises_if_not_started(self):
|
|
"""get_client() should raise RuntimeError if manager not started."""
|
|
from app.http_client import HttpClientManager
|
|
from app.config import Settings
|
|
|
|
settings = Settings()
|
|
manager = HttpClientManager(settings)
|
|
|
|
with pytest.raises(RuntimeError, match="not started"):
|
|
manager.get_client()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_run_blocking_executes_in_thread_pool(self):
|
|
"""run_blocking should execute sync functions in thread pool."""
|
|
from app.http_client import HttpClientManager
|
|
from app.config import Settings
|
|
import threading
|
|
|
|
settings = Settings()
|
|
manager = HttpClientManager(settings)
|
|
await manager.startup()
|
|
|
|
main_thread = threading.current_thread()
|
|
execution_thread = None
|
|
|
|
def blocking_func():
|
|
nonlocal execution_thread
|
|
execution_thread = threading.current_thread()
|
|
return "result"
|
|
|
|
result = await manager.run_blocking(blocking_func)
|
|
|
|
assert result == "result"
|
|
assert execution_thread is not main_thread
|
|
|
|
await manager.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_run_blocking_raises_if_not_started(self):
|
|
"""run_blocking should raise RuntimeError if manager not started."""
|
|
from app.http_client import HttpClientManager
|
|
from app.config import Settings
|
|
|
|
settings = Settings()
|
|
manager = HttpClientManager(settings)
|
|
|
|
with pytest.raises(RuntimeError, match="not started"):
|
|
await manager.run_blocking(lambda: None)
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_startup_idempotent(self):
|
|
"""Calling startup multiple times should be safe."""
|
|
from app.http_client import HttpClientManager
|
|
from app.config import Settings
|
|
|
|
settings = Settings()
|
|
manager = HttpClientManager(settings)
|
|
|
|
await manager.startup()
|
|
client1 = manager._default_client
|
|
|
|
await manager.startup() # Should not create a new client
|
|
client2 = manager._default_client
|
|
|
|
assert client1 is client2 # Same client instance
|
|
|
|
await manager.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_shutdown_idempotent(self):
|
|
"""Calling shutdown multiple times should be safe."""
|
|
from app.http_client import HttpClientManager
|
|
from app.config import Settings
|
|
|
|
settings = Settings()
|
|
manager = HttpClientManager(settings)
|
|
|
|
await manager.startup()
|
|
await manager.shutdown()
|
|
await manager.shutdown() # Should not raise
|
|
|
|
assert manager._default_client is None
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_properties_return_configured_values(self):
|
|
"""Properties should return configured values."""
|
|
from app.http_client import HttpClientManager
|
|
from app.config import Settings
|
|
|
|
settings = Settings(
|
|
http_max_connections=75,
|
|
http_worker_threads=16,
|
|
)
|
|
manager = HttpClientManager(settings)
|
|
await manager.startup()
|
|
|
|
assert manager.pool_size == 75
|
|
assert manager.executor_max == 16
|
|
|
|
await manager.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_active_connections_when_not_started(self):
|
|
"""active_connections should return 0 when not started."""
|
|
from app.http_client import HttpClientManager
|
|
from app.config import Settings
|
|
|
|
settings = Settings()
|
|
manager = HttpClientManager(settings)
|
|
|
|
assert manager.active_connections == 0
|