feat: add HttpClientManager with connection pooling
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.
This commit is contained in:
194
backend/tests/unit/test_http_client.py
Normal file
194
backend/tests/unit/test_http_client.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user