From 1d4af20d79d2da0c825c1fc99d450414d2dfc464 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 16 Sep 2025 02:29:10 +0000
Subject: [PATCH 1/5] Initial plan
From 51d4bd9692cce77402baefaae965f898b5133752 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 16 Sep 2025 02:40:44 +0000
Subject: [PATCH 2/5] Implement FastAPI local server and connection performance
tests
Co-authored-by: nicmostert <142271462+nicmostert@users.noreply.github.com>
---
hurl/schemas/responses/status.py | 1 +
pyproject.toml | 2 +
tests/conftest.py | 138 ++++++
tests/performance/__init__.py | 1 +
tests/performance/local_server.py | 219 ++++++++++
.../performance/test_concurrency_features.py | 360 ++++++++++++++++
tests/performance/test_connection_features.py | 235 ++++++++++
tests/performance/test_protocol_features.py | 223 ++++++++++
tests/performance/test_timeout_retry.py | 407 ++++++++++++++++++
9 files changed, 1586 insertions(+)
create mode 100644 tests/performance/__init__.py
create mode 100644 tests/performance/local_server.py
create mode 100644 tests/performance/test_concurrency_features.py
create mode 100644 tests/performance/test_connection_features.py
create mode 100644 tests/performance/test_protocol_features.py
create mode 100644 tests/performance/test_timeout_retry.py
diff --git a/hurl/schemas/responses/status.py b/hurl/schemas/responses/status.py
index ef0bc26..34517f3 100644
--- a/hurl/schemas/responses/status.py
+++ b/hurl/schemas/responses/status.py
@@ -5,6 +5,7 @@
import xmltodict
from pydantic import BaseModel, Field, field_validator
+from hurl.exceptions import HilltopParseError
from hurl.schemas.mixins import ModelReprMixin
from hurl.schemas.requests import StatusRequest
diff --git a/pyproject.toml b/pyproject.toml
index 1671115..109edda 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,4 +33,6 @@ markers = [
"slow: marks tests as slow (deselect as '-m \"not slow\"')",
"remote: marks tests as remote (deselect as '-m \"not remote\"')",
"update: updates the api response data cache (deselect as '-m \"not update\"')",
+ "performance_local: marks tests as performance tests for local server",
+ "performance_remote: marks tests as performance tests for remote server",
]
diff --git a/tests/conftest.py b/tests/conftest.py
index 072326b..2263f90 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,6 +1,10 @@
"""Shared fixtures for testing."""
+import asyncio
+import os
from pathlib import Path
+import threading
+import time
import pytest
from lxml import etree
@@ -14,6 +18,45 @@ def pytest_addoption(parser):
default=False,
help="Update the cached XML files with the latest data.",
)
+ parser.addoption(
+ "--performance-local",
+ action="store_true",
+ default=False,
+ help="Run performance tests against local FastAPI test server.",
+ )
+ parser.addoption(
+ "--performance-remote",
+ action="store_true",
+ default=False,
+ help="Run performance tests against remote internet-accessible server.",
+ )
+
+
+def pytest_configure(config):
+ """Configure pytest with custom behavior."""
+ # Mark performance tests to skip by default unless explicitly requested
+ if not config.getoption("--performance-local") and not config.getoption("--performance-remote"):
+ # Add skip marker for performance tests when not explicitly requested
+ config.addinivalue_line(
+ "markers",
+ "performance_local: skip unless --performance-local is specified"
+ )
+ config.addinivalue_line(
+ "markers",
+ "performance_remote: skip unless --performance-remote is specified"
+ )
+
+
+def pytest_collection_modifyitems(config, items):
+ """Modify test collection to handle performance test skipping."""
+ skip_local = pytest.mark.skip(reason="Performance local tests require --performance-local flag")
+ skip_remote = pytest.mark.skip(reason="Performance remote tests require --performance-remote flag")
+
+ for item in items:
+ if "performance_local" in item.keywords and not config.getoption("--performance-local"):
+ item.add_marker(skip_local)
+ if "performance_remote" in item.keywords and not config.getoption("--performance-remote"):
+ item.add_marker(skip_remote)
def remove_tags(xml_str, tags_to_remove):
@@ -72,3 +115,98 @@ def _factory(response_xml="payload", status_code=200):
"response": mock_response,
}
return _factory
+
+
+@pytest.fixture(scope="session")
+def local_test_server():
+ """Start a local FastAPI test server for performance testing."""
+ from tests.performance.local_server import create_test_server
+ import threading
+ import time
+ import uvicorn
+ import httpx
+
+ # Configuration from environment variables
+ host = "127.0.0.1"
+ port = int(os.getenv("TEST_SERVER_PORT", "8001")) # Use 8001 to avoid conflicts
+ delay = float(os.getenv("TEST_SERVER_DELAY", "0.01")) # Small default delay for testing
+ error_rate = float(os.getenv("TEST_SERVER_ERROR_RATE", "0.0"))
+
+ server = create_test_server(host=host, port=port, delay=delay, error_rate=error_rate)
+
+ # Start server in a separate thread
+ server_thread = threading.Thread(
+ target=server.run,
+ kwargs={"access_log": False},
+ daemon=True
+ )
+ server_thread.start()
+
+ # Wait for server to start
+ server_url = f"http://{host}:{port}"
+ max_retries = 50 # Increase retries
+ for i in range(max_retries):
+ try:
+ response = httpx.get(f"{server_url}/health", timeout=2.0)
+ if response.status_code == 200:
+ break
+ except (httpx.RequestError, httpx.TimeoutException):
+ pass
+ time.sleep(0.1)
+ else:
+ pytest.fail(f"Local test server failed to start after {max_retries * 0.1}s")
+
+ yield {
+ "base_url": server_url,
+ "hts_endpoint": "foo.hts",
+ "host": host,
+ "port": port,
+ "delay": delay,
+ "error_rate": error_rate
+ }
+
+ # Server cleanup happens automatically when thread ends
+
+
+@pytest.fixture(scope="session")
+def performance_remote_client():
+ """Create a remote client for performance testing."""
+ from hurl.client import HilltopClient
+
+ # Check if remote testing is enabled and environment is configured
+ remote_base_url = os.getenv("HILLTOP_PERFORMANCE_BASE_URL")
+ remote_hts_endpoint = os.getenv("HILLTOP_PERFORMANCE_HTS_ENDPOINT")
+
+ if not remote_base_url or not remote_hts_endpoint:
+ pytest.skip(
+ "Remote performance testing requires HILLTOP_PERFORMANCE_BASE_URL "
+ "and HILLTOP_PERFORMANCE_HTS_ENDPOINT environment variables"
+ )
+
+ client = HilltopClient(
+ base_url=remote_base_url,
+ hts_endpoint=remote_hts_endpoint,
+ timeout=30 # Longer timeout for performance tests
+ )
+
+ try:
+ yield client
+ finally:
+ client.close()
+
+
+@pytest.fixture
+def performance_local_client(local_test_server):
+ """Create a client configured for local performance testing."""
+ from hurl.client import HilltopClient
+
+ client = HilltopClient(
+ base_url=local_test_server["base_url"],
+ hts_endpoint=local_test_server["hts_endpoint"],
+ timeout=30
+ )
+
+ try:
+ yield client
+ finally:
+ client.close()
diff --git a/tests/performance/__init__.py b/tests/performance/__init__.py
new file mode 100644
index 0000000..672159e
--- /dev/null
+++ b/tests/performance/__init__.py
@@ -0,0 +1 @@
+"""Performance testing module for HURL httpx features."""
\ No newline at end of file
diff --git a/tests/performance/local_server.py b/tests/performance/local_server.py
new file mode 100644
index 0000000..9e39467
--- /dev/null
+++ b/tests/performance/local_server.py
@@ -0,0 +1,219 @@
+"""Local FastAPI test server for performance testing.
+
+This module provides a FastAPI-based test server that serves fixture data
+from the cache with configurable delays and error simulation for testing
+httpx performance features like connection pooling, keep-alive, HTTP/2, etc.
+"""
+
+import asyncio
+import os
+import time
+from pathlib import Path
+from typing import Optional
+
+from fastapi import FastAPI, HTTPException, Response
+from fastapi.responses import PlainTextResponse
+import uvicorn
+
+
+class LocalTestServer:
+ """FastAPI-based local test server for performance testing."""
+
+ def __init__(
+ self,
+ host: str = "127.0.0.1",
+ port: int = 8000,
+ fixture_cache_path: Optional[Path] = None,
+ default_delay: float = 0.0,
+ error_rate: float = 0.0
+ ):
+ """Initialize the local test server.
+
+ Args:
+ host: Host to bind to (default: 127.0.0.1)
+ port: Port to bind to (default: 8000)
+ fixture_cache_path: Path to fixture cache directory
+ default_delay: Default artificial delay in seconds
+ error_rate: Rate of errors to simulate (0.0-1.0)
+ """
+ self.host = host
+ self.port = port
+ self.default_delay = default_delay
+ self.error_rate = error_rate
+
+ # Set fixture cache path
+ if fixture_cache_path is None:
+ self.fixture_cache_path = Path(__file__).parent.parent / "fixture_cache"
+ else:
+ self.fixture_cache_path = fixture_cache_path
+
+ self.app = FastAPI(
+ title="HURL Performance Test Server",
+ description="Local test server for httpx performance testing",
+ version="1.0.0"
+ )
+
+ # Hardcoded fixture mapping (can be extended to config-based later)
+ self.fixture_mapping = {
+ # StatusRequest endpoint
+ "/foo.hts": {
+ "Service=Hilltop&Request=Status": "status/response.xml",
+ },
+ # SiteListRequest endpoint
+ "/foo.hts": {
+ "Service=Hilltop&Request=SiteList": "site_list/all_response.xml",
+ },
+ # MeasurementListRequest endpoint
+ "/foo.hts": {
+ "Service=Hilltop&Request=MeasurementList": "measurement_list/all_response.xml",
+ },
+ # GetDataRequest endpoint
+ "/foo.hts": {
+ "Service=Hilltop&Request=GetData": "get_data/basic_response.xml",
+ },
+ }
+
+ self._setup_routes()
+
+ def _setup_routes(self):
+ """Setup FastAPI routes."""
+
+ @self.app.get("/health")
+ async def health_check():
+ """Health check endpoint."""
+ return {"status": "healthy", "server": "hurl-performance-test"}
+
+ @self.app.get("/foo.hts")
+ async def hilltop_endpoint(service: str = None, request: str = None):
+ """Main Hilltop-compatible endpoint."""
+
+ # Simulate artificial delay if configured
+ if self.default_delay > 0:
+ await asyncio.sleep(self.default_delay)
+
+ # Simulate errors if configured
+ if self.error_rate > 0 and time.time() % 1.0 < self.error_rate:
+ raise HTTPException(status_code=500, detail="Simulated server error")
+
+ # Handle case where parameters might be case-insensitive
+ if not service:
+ service = "Hilltop" # Default service
+ if not request:
+ request = "Status" # Default request
+
+ # Create query key for fixture mapping
+ query_key = f"Service={service}&Request={request}"
+
+ # Find matching fixture
+ fixture_file = None
+ if query_key in self.fixture_mapping.get("/foo.hts", {}):
+ fixture_file = self.fixture_mapping["/foo.hts"][query_key]
+ else:
+ # Default fallback - try to find any matching fixture
+ if request == "Status":
+ fixture_file = "status/response.xml"
+ elif request == "SiteList":
+ fixture_file = "site_list/all_response.xml"
+ elif request == "MeasurementList":
+ fixture_file = "measurement_list/all_response.xml"
+ elif request == "GetData":
+ fixture_file = "get_data/basic_response.xml"
+
+ if not fixture_file:
+ # Fallback to default response
+ fixture_file = "status/response.xml"
+
+ # Load fixture content
+ fixture_path = self.fixture_cache_path / fixture_file
+ if not fixture_path.exists():
+ # Create a minimal default response if fixture doesn't exist
+ default_xml = f"""
+
+ Test
+ MockServer-1.0
+ foo.hts
+ 12345
+
+ test.dsn
+ 1
+
+"""
+ return PlainTextResponse(
+ content=default_xml,
+ media_type="text/xml"
+ )
+
+ try:
+ xml_content = fixture_path.read_text(encoding="utf-8")
+ return PlainTextResponse(
+ content=xml_content,
+ media_type="text/xml"
+ )
+ except Exception as e:
+ # Fallback to default XML on error
+ default_xml = f"""
+
+ Test
+ MockServer-1.0
+ foo.hts
+ 12345
+
+ test.dsn
+ 1
+
+"""
+ return PlainTextResponse(
+ content=default_xml,
+ media_type="text/xml"
+ )
+
+ def run(self, **kwargs):
+ """Run the server."""
+ # Set default values but allow override
+ run_kwargs = {
+ "host": self.host,
+ "port": self.port,
+ "log_level": "warning", # Reduce noise during testing
+ }
+ run_kwargs.update(kwargs)
+
+ uvicorn.run(self.app, **run_kwargs)
+
+ async def run_async(self, **kwargs):
+ """Run the server asynchronously."""
+ config = uvicorn.Config(
+ self.app,
+ host=self.host,
+ port=self.port,
+ log_level="warning",
+ **kwargs
+ )
+ server = uvicorn.Server(config)
+ await server.serve()
+
+
+def create_test_server(
+ host: str = "127.0.0.1",
+ port: int = 8000,
+ delay: float = 0.0,
+ error_rate: float = 0.0
+) -> LocalTestServer:
+ """Factory function to create a test server with specified configuration."""
+ return LocalTestServer(
+ host=host,
+ port=port,
+ default_delay=delay,
+ error_rate=error_rate
+ )
+
+
+if __name__ == "__main__":
+ # Allow running server directly for manual testing
+ delay = float(os.getenv("TEST_SERVER_DELAY", "0.0"))
+ error_rate = float(os.getenv("TEST_SERVER_ERROR_RATE", "0.0"))
+ port = int(os.getenv("TEST_SERVER_PORT", "8000"))
+
+ server = create_test_server(delay=delay, error_rate=error_rate, port=port)
+ print(f"Starting HURL Performance Test Server on http://127.0.0.1:{port}")
+ print(f"Configuration: delay={delay}s, error_rate={error_rate}")
+ server.run()
\ No newline at end of file
diff --git a/tests/performance/test_concurrency_features.py b/tests/performance/test_concurrency_features.py
new file mode 100644
index 0000000..218e4d1
--- /dev/null
+++ b/tests/performance/test_concurrency_features.py
@@ -0,0 +1,360 @@
+"""Tests for httpx concurrency features.
+
+This module tests httpx performance features related to concurrency:
+- Sync vs async client performance
+- Concurrent request handling
+- Async context manager usage
+"""
+
+import asyncio
+import time
+from concurrent.futures import ThreadPoolExecutor
+
+import httpx
+import pytest
+
+from hurl.client import HilltopClient
+
+
+class TestSyncVsAsyncPerformance:
+ """Compare synchronous vs asynchronous client performance."""
+
+ @pytest.mark.performance_local
+ def test_sync_sequential_requests(self, benchmark, local_test_server):
+ """Test synchronous sequential request performance."""
+
+ def make_sync_requests():
+ """Make sequential requests using sync client."""
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ timeout=30.0
+ ) as client:
+ results = []
+ for _ in range(5):
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ results.append(response)
+ return results
+
+ results = benchmark(make_sync_requests)
+
+ # Verify all requests succeeded
+ assert len(results) == 5
+ for result in results:
+ assert result.status_code == 200
+
+ @pytest.mark.performance_local
+ def test_async_sequential_requests(self, benchmark, local_test_server):
+ """Test asynchronous sequential request performance."""
+
+ async def make_async_requests():
+ """Make sequential requests using async client."""
+ async with httpx.AsyncClient(
+ base_url=local_test_server["base_url"],
+ timeout=30.0
+ ) as client:
+ results = []
+ for _ in range(5):
+ response = await client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ results.append(response)
+ return results
+
+ # Use pytest-benchmark's async support
+ results = benchmark.pedantic(
+ asyncio.run,
+ args=[make_async_requests()],
+ rounds=3,
+ iterations=1
+ )
+
+ # Verify all requests succeeded
+ assert len(results) == 5
+ for result in results:
+ assert result.status_code == 200
+
+ @pytest.mark.performance_local
+ def test_sync_vs_async_comparison(self, local_test_server):
+ """Compare sync vs async performance for the same workload."""
+
+ # Sync performance
+ start_time = time.perf_counter()
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ timeout=30.0
+ ) as client:
+ sync_results = []
+ for _ in range(10):
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ sync_results.append(response)
+ sync_time = time.perf_counter() - start_time
+
+ # Async performance (sequential)
+ async def async_sequential():
+ async with httpx.AsyncClient(
+ base_url=local_test_server["base_url"],
+ timeout=30.0
+ ) as client:
+ async_results = []
+ for _ in range(10):
+ response = await client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ async_results.append(response)
+ return async_results
+
+ start_time = time.perf_counter()
+ async_results = asyncio.run(async_sequential())
+ async_time = time.perf_counter() - start_time
+
+ # Verify both approaches work
+ assert len(sync_results) == 10
+ assert len(async_results) == 10
+
+ for result in sync_results + async_results:
+ assert result.status_code == 200
+
+ # Record performance metrics
+ print(f"Sync sequential time: {sync_time:.4f}s")
+ print(f"Async sequential time: {async_time:.4f}s")
+ print(f"Sync RPS: {10/sync_time:.2f}")
+ print(f"Async RPS: {10/async_time:.2f}")
+
+
+class TestConcurrentRequests:
+ """Test concurrent request performance."""
+
+ @pytest.mark.performance_local
+ def test_sync_concurrent_with_threads(self, benchmark, local_test_server):
+ """Test sync client with thread-based concurrency."""
+
+ def make_single_request():
+ """Make a single request."""
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ timeout=30.0
+ ) as client:
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ return response
+
+ def make_concurrent_sync_requests():
+ """Make concurrent requests using ThreadPoolExecutor."""
+ with ThreadPoolExecutor(max_workers=5) as executor:
+ futures = [executor.submit(make_single_request) for _ in range(10)]
+ results = [future.result() for future in futures]
+ return results
+
+ results = benchmark(make_concurrent_sync_requests)
+
+ # Verify all requests succeeded
+ assert len(results) == 10
+ for result in results:
+ assert result.status_code == 200
+
+ @pytest.mark.performance_local
+ def test_async_concurrent_requests(self, benchmark, local_test_server):
+ """Test async client with asyncio concurrency."""
+
+ async def make_async_concurrent_requests():
+ """Make concurrent requests using asyncio.gather."""
+ async with httpx.AsyncClient(
+ base_url=local_test_server["base_url"],
+ limits=httpx.Limits(max_connections=10),
+ timeout=30.0
+ ) as client:
+ tasks = [
+ client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ for _ in range(10)
+ ]
+ results = await asyncio.gather(*tasks)
+ return results
+
+ results = benchmark.pedantic(
+ asyncio.run,
+ args=[make_async_concurrent_requests()],
+ rounds=3,
+ iterations=1
+ )
+
+ # Verify all requests succeeded
+ assert len(results) == 10
+ for result in results:
+ assert result.status_code == 200
+
+ @pytest.mark.performance_local
+ def test_concurrency_scaling(self, local_test_server):
+ """Test how performance scales with concurrency level."""
+
+ async def test_concurrency_level(concurrency):
+ """Test performance at a specific concurrency level."""
+ async with httpx.AsyncClient(
+ base_url=local_test_server["base_url"],
+ limits=httpx.Limits(max_connections=concurrency),
+ timeout=30.0
+ ) as client:
+ start_time = time.perf_counter()
+ tasks = [
+ client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ for _ in range(concurrency * 2) # 2x requests vs connections
+ ]
+ results = await asyncio.gather(*tasks)
+ end_time = time.perf_counter()
+
+ return {
+ 'concurrency': concurrency,
+ 'requests': len(results),
+ 'time': end_time - start_time,
+ 'rps': len(results) / (end_time - start_time),
+ 'success': all(r.status_code == 200 for r in results)
+ }
+
+ # Test different concurrency levels
+ concurrency_levels = [1, 2, 5, 10]
+ results = []
+
+ for level in concurrency_levels:
+ result = asyncio.run(test_concurrency_level(level))
+ results.append(result)
+
+ # Verify all tests succeeded
+ for result in results:
+ assert result['success']
+ print(f"Concurrency {result['concurrency']}: {result['rps']:.2f} RPS ({result['time']:.4f}s for {result['requests']} requests)")
+
+ # Check that concurrency generally improves performance
+ # (though in test environment this might not always be true)
+ assert all(r['rps'] > 0 for r in results)
+
+
+class TestAsyncContextManagement:
+ """Test async context manager performance."""
+
+ @pytest.mark.performance_local
+ async def test_async_context_manager_reuse(self, local_test_server):
+ """Test reusing async client context manager."""
+
+ async with httpx.AsyncClient(
+ base_url=local_test_server["base_url"],
+ timeout=30.0
+ ) as client:
+ # Multiple requests within same context
+ start_time = time.perf_counter()
+ results = []
+ for _ in range(5):
+ response = await client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ results.append(response)
+ end_time = time.perf_counter()
+
+ # Verify all succeeded
+ assert len(results) == 5
+ for result in results:
+ assert result.status_code == 200
+
+ reuse_time = end_time - start_time
+ print(f"Context reuse time: {reuse_time:.4f}s")
+
+ # Compare with creating new context for each request
+ start_time = time.perf_counter()
+ new_context_results = []
+ for _ in range(5):
+ async with httpx.AsyncClient(
+ base_url=local_test_server["base_url"],
+ timeout=30.0
+ ) as client:
+ response = await client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ new_context_results.append(response)
+ end_time = time.perf_counter()
+
+ new_context_time = end_time - start_time
+ print(f"New context time: {new_context_time:.4f}s")
+ print(f"Context reuse improvement: {((new_context_time - reuse_time) / new_context_time * 100):.1f}%")
+
+ # Verify both approaches work
+ assert len(new_context_results) == 5
+ for result in new_context_results:
+ assert result.status_code == 200
+
+
+class TestHilltopClientConcurrency:
+ """Test HilltopClient in concurrent scenarios."""
+
+ @pytest.mark.performance_local
+ def test_hilltop_client_thread_safety(self, local_test_server):
+ """Test HilltopClient thread safety (though it's not designed for it)."""
+
+ # Note: HilltopClient is not designed to be thread-safe
+ # This test demonstrates the recommended pattern: one client per thread
+
+ def make_requests_with_own_client():
+ """Each thread creates its own client."""
+ with HilltopClient(
+ base_url=local_test_server["base_url"],
+ hts_endpoint=local_test_server["hts_endpoint"],
+ timeout=30
+ ) as client:
+ results = []
+ for _ in range(3):
+ result = client.get_status()
+ results.append(result)
+ return results
+
+ # Use thread pool with separate clients
+ with ThreadPoolExecutor(max_workers=3) as executor:
+ futures = [executor.submit(make_requests_with_own_client) for _ in range(3)]
+ all_results = [future.result() for future in futures]
+
+ # Verify all threads succeeded
+ assert len(all_results) == 3
+ for thread_results in all_results:
+ assert len(thread_results) == 3
+ for result in thread_results:
+ assert hasattr(result, 'request')
+
+ @pytest.mark.performance_local
+ def test_multiple_hilltop_clients(self, local_test_server):
+ """Test performance with multiple HilltopClient instances."""
+
+ start_time = time.perf_counter()
+ results = []
+
+ # Create multiple clients and use them
+ for i in range(5):
+ with HilltopClient(
+ base_url=local_test_server["base_url"],
+ hts_endpoint=local_test_server["hts_endpoint"],
+ timeout=30
+ ) as client:
+ result = client.get_status()
+ results.append(result)
+
+ end_time = time.perf_counter()
+
+ # Verify all succeeded
+ assert len(results) == 5
+ for result in results:
+ assert hasattr(result, 'request')
+
+ multiple_clients_time = end_time - start_time
+ print(f"Multiple clients time: {multiple_clients_time:.4f}s")
+ print(f"Multiple clients RPS: {5/multiple_clients_time:.2f}")
+
+ # Compare with single client reuse
+ start_time = time.perf_counter()
+ with HilltopClient(
+ base_url=local_test_server["base_url"],
+ hts_endpoint=local_test_server["hts_endpoint"],
+ timeout=30
+ ) as client:
+ single_client_results = []
+ for _ in range(5):
+ result = client.get_status()
+ single_client_results.append(result)
+ end_time = time.perf_counter()
+
+ single_client_time = end_time - start_time
+ print(f"Single client time: {single_client_time:.4f}s")
+ print(f"Single client RPS: {5/single_client_time:.2f}")
+
+ # Verify both approaches work
+ assert len(single_client_results) == 5
+ for result in single_client_results:
+ assert hasattr(result, 'request')
+
+ # Single client reuse should typically be faster
+ print(f"Single client improvement: {((multiple_clients_time - single_client_time) / multiple_clients_time * 100):.1f}%")
\ No newline at end of file
diff --git a/tests/performance/test_connection_features.py b/tests/performance/test_connection_features.py
new file mode 100644
index 0000000..3826458
--- /dev/null
+++ b/tests/performance/test_connection_features.py
@@ -0,0 +1,235 @@
+"""Tests for httpx connection management features.
+
+This module tests httpx performance features related to connection management:
+- Keep-alive connections vs new connections
+- Connection pooling vs non-pooled connections
+- Connection reuse patterns
+"""
+
+import asyncio
+import time
+from unittest.mock import patch
+
+import httpx
+import pytest
+
+from hurl.client import HilltopClient
+
+
+class TestConnectionKeepAlive:
+ """Test connection keep-alive functionality."""
+
+ @pytest.mark.performance_local
+ def test_sequential_requests_with_keep_alive(self, benchmark, performance_local_client):
+ """Test performance of sequential requests with keep-alive enabled."""
+
+ def make_requests():
+ """Make multiple sequential requests using the same client session."""
+ results = []
+ for _ in range(5):
+ response = performance_local_client.get_status()
+ results.append(response)
+ return results
+
+ # Benchmark with keep-alive (default httpx behavior)
+ results = benchmark(make_requests)
+
+ # Verify all requests succeeded
+ assert len(results) == 5
+ for result in results:
+ assert hasattr(result, 'request')
+
+ @pytest.mark.performance_local
+ def test_sequential_requests_without_keep_alive(self, benchmark, local_test_server):
+ """Test performance of sequential requests without keep-alive."""
+
+ def make_requests_new_client():
+ """Make multiple requests, creating a new client for each."""
+ results = []
+ for _ in range(5):
+ # Create new client for each request (no connection reuse)
+ with HilltopClient(
+ base_url=local_test_server["base_url"],
+ hts_endpoint=local_test_server["hts_endpoint"],
+ timeout=30
+ ) as client:
+ result = client.get_status()
+ results.append(result)
+ return results
+
+ # Benchmark without keep-alive (new client each time)
+ results = benchmark(make_requests_new_client)
+
+ # Verify all requests succeeded
+ assert len(results) == 5
+ for result in results:
+ assert hasattr(result, 'request')
+
+ @pytest.mark.performance_local
+ def test_keep_alive_vs_new_connections_comparison(self, local_test_server):
+ """Compare keep-alive vs new connections performance."""
+
+ # Test with keep-alive (reuse connection)
+ start_time = time.perf_counter()
+ with HilltopClient(
+ base_url=local_test_server["base_url"],
+ hts_endpoint=local_test_server["hts_endpoint"],
+ timeout=30
+ ) as client:
+ for _ in range(10):
+ client.get_status()
+ keep_alive_time = time.perf_counter() - start_time
+
+ # Test without keep-alive (new connection each time)
+ start_time = time.perf_counter()
+ for _ in range(10):
+ with HilltopClient(
+ base_url=local_test_server["base_url"],
+ hts_endpoint=local_test_server["hts_endpoint"],
+ timeout=30
+ ) as client:
+ client.get_status()
+ new_connection_time = time.perf_counter() - start_time
+
+ # Keep-alive should be faster (but allow some tolerance for test environment variability)
+ print(f"Keep-alive time: {keep_alive_time:.4f}s")
+ print(f"New connection time: {new_connection_time:.4f}s")
+ print(f"Performance improvement: {((new_connection_time - keep_alive_time) / new_connection_time * 100):.1f}%")
+
+ # Keep-alive should typically be faster, but in test environments this might be minimal
+ # So we just verify both approaches work and record the metrics
+ assert keep_alive_time > 0
+ assert new_connection_time > 0
+
+
+class TestConnectionPooling:
+ """Test connection pooling features."""
+
+ @pytest.mark.performance_local
+ def test_pooled_vs_non_pooled_requests(self, benchmark, local_test_server):
+ """Compare pooled vs non-pooled connection performance."""
+
+ def make_pooled_requests():
+ """Make requests using httpx client with connection pooling."""
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
+ timeout=30.0
+ ) as client:
+ results = []
+ for _ in range(5):
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ results.append(response)
+ return results
+
+ def make_non_pooled_requests():
+ """Make requests using httpx with minimal connection limits."""
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ limits=httpx.Limits(max_connections=1, max_keepalive_connections=0),
+ timeout=30.0
+ ) as client:
+ results = []
+ for _ in range(5):
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ results.append(response)
+ return results
+
+ # Benchmark pooled requests
+ pooled_results = benchmark.pedantic(make_pooled_requests, rounds=3, iterations=1)
+
+ # Benchmark non-pooled requests
+ non_pooled_results = benchmark.pedantic(make_non_pooled_requests, rounds=3, iterations=1)
+
+ # Verify both approaches work
+ assert len(pooled_results) == 5
+ assert len(non_pooled_results) == 5
+
+ for result in pooled_results + non_pooled_results:
+ assert result.status_code == 200
+
+ @pytest.mark.performance_local
+ def test_connection_pool_limits(self, local_test_server):
+ """Test behavior with different connection pool limits."""
+
+ # Test with small pool
+ small_pool_times = []
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ limits=httpx.Limits(max_connections=2, max_keepalive_connections=1),
+ timeout=30.0
+ ) as client:
+ for _ in range(5):
+ start = time.perf_counter()
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ end = time.perf_counter()
+ small_pool_times.append(end - start)
+ assert response.status_code == 200
+
+ # Test with large pool
+ large_pool_times = []
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+ timeout=30.0
+ ) as client:
+ for _ in range(5):
+ start = time.perf_counter()
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ end = time.perf_counter()
+ large_pool_times.append(end - start)
+ assert response.status_code == 200
+
+ # Record metrics for analysis
+ avg_small = sum(small_pool_times) / len(small_pool_times)
+ avg_large = sum(large_pool_times) / len(large_pool_times)
+
+ print(f"Small pool average time: {avg_small:.4f}s")
+ print(f"Large pool average time: {avg_large:.4f}s")
+
+ # Both should complete successfully
+ assert avg_small > 0
+ assert avg_large > 0
+
+
+class TestConnectionConfiguration:
+ """Test different httpx connection configurations."""
+
+ @pytest.mark.performance_local
+ def test_hilltop_client_connection_config(self, local_test_server):
+ """Test HilltopClient's default connection configuration."""
+
+ with HilltopClient(
+ base_url=local_test_server["base_url"],
+ hts_endpoint=local_test_server["hts_endpoint"],
+ timeout=30
+ ) as client:
+ # Verify the client session has expected configuration
+ assert client.session.limits.max_connections == 10 # From client.py
+ assert client.session.timeout.timeout == 30
+ assert client.session.follow_redirects is True
+
+ # Test that connections work
+ response = client.get_status()
+ assert hasattr(response, 'request')
+
+ @pytest.mark.performance_local
+ def test_custom_connection_configuration(self, local_test_server):
+ """Test custom httpx connection configurations."""
+
+ # Test various configurations
+ configs = [
+ {"max_connections": 5, "max_keepalive_connections": 2},
+ {"max_connections": 20, "max_keepalive_connections": 10},
+ {"max_connections": 1, "max_keepalive_connections": 0},
+ ]
+
+ for config in configs:
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ limits=httpx.Limits(**config),
+ timeout=30.0
+ ) as client:
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ assert response.status_code == 200
+ assert "Test" in response.text # Basic XML validation
\ No newline at end of file
diff --git a/tests/performance/test_protocol_features.py b/tests/performance/test_protocol_features.py
new file mode 100644
index 0000000..7af1656
--- /dev/null
+++ b/tests/performance/test_protocol_features.py
@@ -0,0 +1,223 @@
+"""Tests for httpx protocol features.
+
+This module tests httpx performance features related to HTTP protocols:
+- HTTP/1.1 vs HTTP/2 performance
+- Protocol negotiation
+- Protocol-specific optimizations
+"""
+
+import asyncio
+import time
+
+import httpx
+import pytest
+
+from hurl.client import HilltopClient
+
+
+class TestHTTPProtocolPerformance:
+ """Test HTTP protocol performance characteristics."""
+
+ @pytest.mark.performance_local
+ def test_http1_sequential_requests(self, benchmark, local_test_server):
+ """Test HTTP/1.1 sequential request performance."""
+
+ def make_http1_requests():
+ """Make requests using HTTP/1.1 explicitly."""
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ http2=False, # Force HTTP/1.1
+ limits=httpx.Limits(max_connections=10),
+ timeout=30.0
+ ) as client:
+ results = []
+ for _ in range(5):
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ results.append(response)
+ return results
+
+ results = benchmark(make_http1_requests)
+
+ # Verify all requests succeeded
+ assert len(results) == 5
+ for result in results:
+ assert result.status_code == 200
+ # Verify we're using HTTP/1.1
+ assert result.http_version == "HTTP/1.1"
+
+ @pytest.mark.performance_local
+ def test_http2_sequential_requests(self, benchmark, local_test_server):
+ """Test HTTP/2 sequential request performance."""
+
+ def make_http2_requests():
+ """Make requests using HTTP/2 if available."""
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ http2=True, # Enable HTTP/2
+ limits=httpx.Limits(max_connections=10),
+ timeout=30.0
+ ) as client:
+ results = []
+ for _ in range(5):
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ results.append(response)
+ return results
+
+ results = benchmark(make_http2_requests)
+
+ # Verify all requests succeeded
+ assert len(results) == 5
+ for result in results:
+ assert result.status_code == 200
+ # Note: Local FastAPI server may not support HTTP/2, so we don't assert version
+
+ @pytest.mark.performance_local
+ def test_protocol_comparison(self, local_test_server):
+ """Compare HTTP/1.1 vs HTTP/2 performance characteristics."""
+
+ # Test HTTP/1.1 performance
+ start_time = time.perf_counter()
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ http2=False,
+ limits=httpx.Limits(max_connections=5),
+ timeout=30.0
+ ) as client:
+ http1_responses = []
+ for _ in range(10):
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ http1_responses.append(response)
+ http1_time = time.perf_counter() - start_time
+
+ # Test HTTP/2 performance
+ start_time = time.perf_counter()
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ http2=True,
+ limits=httpx.Limits(max_connections=5),
+ timeout=30.0
+ ) as client:
+ http2_responses = []
+ for _ in range(10):
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ http2_responses.append(response)
+ http2_time = time.perf_counter() - start_time
+
+ # Verify both protocols work
+ assert len(http1_responses) == 10
+ assert len(http2_responses) == 10
+
+ for response in http1_responses:
+ assert response.status_code == 200
+ assert response.http_version == "HTTP/1.1"
+
+ for response in http2_responses:
+ assert response.status_code == 200
+ # HTTP version may be 1.1 or 2.0 depending on server support
+
+ # Record performance metrics
+ print(f"HTTP/1.1 time: {http1_time:.4f}s")
+ print(f"HTTP/2 time: {http2_time:.4f}s")
+ print(f"Requests per second (HTTP/1.1): {10/http1_time:.2f}")
+ print(f"Requests per second (HTTP/2): {10/http2_time:.2f}")
+
+
+class TestConcurrentProtocolRequests:
+ """Test concurrent request performance with different protocols."""
+
+ @pytest.mark.performance_local
+ async def test_concurrent_http1_requests(self, local_test_server):
+ """Test concurrent HTTP/1.1 requests."""
+
+ async def make_request(client):
+ """Make a single async request."""
+ response = await client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ return response
+
+ async with httpx.AsyncClient(
+ base_url=local_test_server["base_url"],
+ http2=False,
+ limits=httpx.Limits(max_connections=10),
+ timeout=30.0
+ ) as client:
+ # Make 10 concurrent requests
+ start_time = time.perf_counter()
+ tasks = [make_request(client) for _ in range(10)]
+ responses = await asyncio.gather(*tasks)
+ end_time = time.perf_counter()
+
+ # Verify all requests succeeded
+ assert len(responses) == 10
+ for response in responses:
+ assert response.status_code == 200
+ assert response.http_version == "HTTP/1.1"
+
+ concurrent_time = end_time - start_time
+ print(f"Concurrent HTTP/1.1 time: {concurrent_time:.4f}s")
+ print(f"Concurrent requests per second: {10/concurrent_time:.2f}")
+
+ @pytest.mark.performance_local
+ async def test_concurrent_http2_requests(self, local_test_server):
+ """Test concurrent HTTP/2 requests."""
+
+ async def make_request(client):
+ """Make a single async request."""
+ response = await client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ return response
+
+ async with httpx.AsyncClient(
+ base_url=local_test_server["base_url"],
+ http2=True,
+ limits=httpx.Limits(max_connections=10),
+ timeout=30.0
+ ) as client:
+ # Make 10 concurrent requests
+ start_time = time.perf_counter()
+ tasks = [make_request(client) for _ in range(10)]
+ responses = await asyncio.gather(*tasks)
+ end_time = time.perf_counter()
+
+ # Verify all requests succeeded
+ assert len(responses) == 10
+ for response in responses:
+ assert response.status_code == 200
+ # HTTP version depends on server support
+
+ concurrent_time = end_time - start_time
+ print(f"Concurrent HTTP/2 time: {concurrent_time:.4f}s")
+ print(f"Concurrent requests per second: {10/concurrent_time:.2f}")
+
+
+class TestProtocolConfiguration:
+ """Test protocol configuration in HilltopClient."""
+
+ @pytest.mark.performance_local
+ def test_hilltop_client_default_protocol(self, local_test_server):
+ """Test HilltopClient's default protocol configuration."""
+
+ with HilltopClient(
+ base_url=local_test_server["base_url"],
+ hts_endpoint=local_test_server["hts_endpoint"],
+ timeout=30
+ ) as client:
+ # Make a request to see what protocol is used
+ response = client.get_status()
+ assert hasattr(response, 'request')
+
+ # Check the underlying session configuration
+ # HilltopClient uses httpx.Client which defaults to HTTP/1.1 unless http2=True
+ assert hasattr(client.session, 'limits')
+
+ @pytest.mark.performance_local
+ def test_protocol_fallback_behavior(self, local_test_server):
+ """Test protocol fallback behavior."""
+
+ # Test that HTTP/2 client can fallback to HTTP/1.1 if needed
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ http2=True, # Enable HTTP/2 but allow fallback
+ timeout=30.0
+ ) as client:
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ assert response.status_code == 200
+ # Should work regardless of what protocol the server actually supports
\ No newline at end of file
diff --git a/tests/performance/test_timeout_retry.py b/tests/performance/test_timeout_retry.py
new file mode 100644
index 0000000..e0b2155
--- /dev/null
+++ b/tests/performance/test_timeout_retry.py
@@ -0,0 +1,407 @@
+"""Tests for httpx timeout and retry configuration effects.
+
+This module tests httpx performance characteristics related to:
+- Different timeout configurations
+- Retry behavior and performance impact
+- Error handling and recovery
+"""
+
+import asyncio
+import time
+from unittest.mock import patch
+
+import httpx
+import pytest
+
+from hurl.client import HilltopClient
+
+
+class TestTimeoutConfiguration:
+ """Test different timeout configuration effects."""
+
+ @pytest.mark.performance_local
+ def test_short_timeout_performance(self, benchmark, local_test_server):
+ """Test performance with short timeout values."""
+
+ def make_requests_short_timeout():
+ """Make requests with short timeout."""
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ timeout=1.0, # Very short timeout
+ ) as client:
+ results = []
+ for _ in range(3):
+ try:
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ results.append(response)
+ except httpx.TimeoutException:
+ # Count timeouts as results too
+ results.append(None)
+ return results
+
+ results = benchmark(make_requests_short_timeout)
+
+ # Should have attempted all requests
+ assert len(results) == 3
+
+ # Count successful vs timeout responses
+ successful = [r for r in results if r is not None and r.status_code == 200]
+ timeouts = [r for r in results if r is None]
+
+ print(f"Successful requests: {len(successful)}")
+ print(f"Timeout requests: {len(timeouts)}")
+
+ @pytest.mark.performance_local
+ def test_long_timeout_performance(self, benchmark, local_test_server):
+ """Test performance with long timeout values."""
+
+ def make_requests_long_timeout():
+ """Make requests with long timeout."""
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ timeout=30.0, # Long timeout
+ ) as client:
+ results = []
+ for _ in range(3):
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ results.append(response)
+ return results
+
+ results = benchmark(make_requests_long_timeout)
+
+ # All should succeed with long timeout
+ assert len(results) == 3
+ for result in results:
+ assert result.status_code == 200
+
+ @pytest.mark.performance_local
+ def test_timeout_granularity(self, local_test_server):
+ """Test different timeout granularity settings."""
+
+ # Test fine-grained timeout configuration
+ timeout_config = httpx.Timeout(
+ connect=5.0, # Connection timeout
+ read=10.0, # Read timeout
+ write=5.0, # Write timeout
+ pool=15.0 # Pool timeout
+ )
+
+ start_time = time.perf_counter()
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ timeout=timeout_config
+ ) as client:
+ results = []
+ for _ in range(5):
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ results.append(response)
+ end_time = time.perf_counter()
+
+ # Verify all succeeded
+ assert len(results) == 5
+ for result in results:
+ assert result.status_code == 200
+
+ granular_time = end_time - start_time
+ print(f"Granular timeout time: {granular_time:.4f}s")
+
+ # Compare with simple timeout
+ start_time = time.perf_counter()
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ timeout=30.0 # Simple timeout
+ ) as client:
+ simple_results = []
+ for _ in range(5):
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ simple_results.append(response)
+ end_time = time.perf_counter()
+
+ simple_time = end_time - start_time
+ print(f"Simple timeout time: {simple_time:.4f}s")
+
+ # Both should work similarly for successful requests
+ assert len(simple_results) == 5
+ for result in simple_results:
+ assert result.status_code == 200
+
+
+class TestRetryBehavior:
+ """Test retry behavior and its performance impact."""
+
+ @pytest.mark.performance_local
+ def test_no_retry_performance(self, benchmark, local_test_server):
+ """Test performance without retry logic."""
+
+ def make_requests_no_retry():
+ """Make requests without any retry logic."""
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ timeout=30.0
+ ) as client:
+ results = []
+ for _ in range(5):
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ results.append(response)
+ return results
+
+ results = benchmark(make_requests_no_retry)
+
+ assert len(results) == 5
+ for result in results:
+ assert result.status_code == 200
+
+ @pytest.mark.performance_local
+ def test_manual_retry_performance(self, benchmark, local_test_server):
+ """Test performance with manual retry logic."""
+
+ def make_requests_with_retry():
+ """Make requests with manual retry logic."""
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ timeout=5.0
+ ) as client:
+ results = []
+ for _ in range(3): # Fewer requests since we're adding retry overhead
+ max_retries = 3
+ for attempt in range(max_retries):
+ try:
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ results.append(response)
+ break # Success, no need to retry
+ except (httpx.TimeoutException, httpx.RequestError) as e:
+ if attempt == max_retries - 1:
+ # Last attempt failed, record the failure
+ results.append(None)
+ else:
+ # Wait before retry
+ time.sleep(0.1 * (attempt + 1)) # Exponential backoff
+ return results
+
+ results = benchmark(make_requests_with_retry)
+
+ assert len(results) == 3
+ successful = [r for r in results if r is not None and r.status_code == 200]
+ failed = [r for r in results if r is None]
+
+ print(f"Successful with retry: {len(successful)}")
+ print(f"Failed after retries: {len(failed)}")
+
+ @pytest.mark.performance_local
+ def test_retry_vs_no_retry_comparison(self, local_test_server):
+ """Compare performance with and without retry logic."""
+
+ # Test without retry
+ start_time = time.perf_counter()
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ timeout=30.0
+ ) as client:
+ no_retry_results = []
+ for _ in range(5):
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ no_retry_results.append(response)
+ no_retry_time = time.perf_counter() - start_time
+
+ # Test with retry logic
+ start_time = time.perf_counter()
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ timeout=30.0
+ ) as client:
+ retry_results = []
+ for _ in range(5):
+ max_retries = 2
+ for attempt in range(max_retries):
+ try:
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ retry_results.append(response)
+ break
+ except (httpx.TimeoutException, httpx.RequestError):
+ if attempt == max_retries - 1:
+ retry_results.append(None)
+ else:
+ time.sleep(0.05) # Small delay between retries
+ retry_time = time.perf_counter() - start_time
+
+ # Both should succeed in this case (no network errors expected)
+ assert len(no_retry_results) == 5
+ assert len(retry_results) == 5
+
+ for result in no_retry_results + retry_results:
+ if result is not None:
+ assert result.status_code == 200
+
+ print(f"No retry time: {no_retry_time:.4f}s")
+ print(f"With retry time: {retry_time:.4f}s")
+ print(f"Retry overhead: {((retry_time - no_retry_time) / no_retry_time * 100):.1f}%")
+
+
+class TestErrorHandlingPerformance:
+ """Test error handling and recovery performance."""
+
+ @pytest.mark.performance_local
+ def test_connection_error_handling(self, local_test_server):
+ """Test performance when handling connection errors."""
+
+ # Test requests to a non-existent endpoint
+ invalid_url = local_test_server["base_url"].replace("8001", "9999") # Wrong port
+
+ start_time = time.perf_counter()
+ error_count = 0
+ with httpx.Client(timeout=1.0) as client:
+ for _ in range(3):
+ try:
+ response = client.get(f"{invalid_url}/foo.hts?Service=Hilltop&Request=Status")
+ except (httpx.ConnectError, httpx.TimeoutException):
+ error_count += 1
+ error_handling_time = time.perf_counter() - start_time
+
+ print(f"Error handling time: {error_handling_time:.4f}s")
+ print(f"Errors encountered: {error_count}")
+
+ # Should have encountered connection errors
+ assert error_count > 0
+
+ @pytest.mark.performance_local
+ def test_mixed_success_failure_performance(self, local_test_server):
+ """Test performance with mixed successful and failed requests."""
+
+ # Simulate a scenario with some failing requests
+ start_time = time.perf_counter()
+ results = []
+
+ with httpx.Client(timeout=2.0) as client:
+ # Mix of valid and invalid requests
+ requests = [
+ f"{local_test_server['base_url']}/foo.hts?Service=Hilltop&Request=Status", # Valid
+ f"{local_test_server['base_url']}/invalid?Service=Hilltop&Request=Status", # Invalid endpoint
+ f"{local_test_server['base_url']}/foo.hts?Service=Hilltop&Request=Status", # Valid
+ f"{local_test_server['base_url']}/foo.hts?Invalid=Request", # Invalid params
+ f"{local_test_server['base_url']}/foo.hts?Service=Hilltop&Request=Status", # Valid
+ ]
+
+ for url in requests:
+ try:
+ response = client.get(url)
+ results.append(('success', response.status_code))
+ except httpx.RequestError as e:
+ results.append(('error', str(type(e).__name__)))
+
+ mixed_time = time.perf_counter() - start_time
+
+ # Count successes and failures
+ successes = [r for r in results if r[0] == 'success' and r[1] == 200]
+ failures = [r for r in results if r[0] == 'error' or (r[0] == 'success' and r[1] != 200)]
+
+ print(f"Mixed requests time: {mixed_time:.4f}s")
+ print(f"Successful requests: {len(successes)}")
+ print(f"Failed requests: {len(failures)}")
+
+ # Should have a mix of successes and failures
+ assert len(results) == 5
+ assert len(successes) > 0 # Some requests should succeed
+ assert len(failures) > 0 # Some requests should fail
+
+
+class TestHilltopClientTimeoutBehavior:
+ """Test HilltopClient timeout behavior."""
+
+ @pytest.mark.performance_local
+ def test_hilltop_client_default_timeout(self, local_test_server):
+ """Test HilltopClient with default timeout configuration."""
+
+ start_time = time.perf_counter()
+ with HilltopClient(
+ base_url=local_test_server["base_url"],
+ hts_endpoint=local_test_server["hts_endpoint"],
+ timeout=60 # Default timeout
+ ) as client:
+ results = []
+ for _ in range(3):
+ result = client.get_status()
+ results.append(result)
+ default_timeout_time = time.perf_counter() - start_time
+
+ assert len(results) == 3
+ for result in results:
+ assert hasattr(result, 'request')
+
+ print(f"Default timeout (60s) time: {default_timeout_time:.4f}s")
+
+ @pytest.mark.performance_local
+ def test_hilltop_client_custom_timeout(self, local_test_server):
+ """Test HilltopClient with custom timeout configuration."""
+
+ # Test with shorter timeout
+ start_time = time.perf_counter()
+ with HilltopClient(
+ base_url=local_test_server["base_url"],
+ hts_endpoint=local_test_server["hts_endpoint"],
+ timeout=10 # Custom shorter timeout
+ ) as client:
+ results = []
+ for _ in range(3):
+ result = client.get_status()
+ results.append(result)
+ custom_timeout_time = time.perf_counter() - start_time
+
+ assert len(results) == 3
+ for result in results:
+ assert hasattr(result, 'request')
+
+ print(f"Custom timeout (10s) time: {custom_timeout_time:.4f}s")
+
+ # For successful requests, timeout value shouldn't significantly affect performance
+ # (the actual network operations are the same)
+ assert custom_timeout_time > 0
+
+ @pytest.mark.performance_local
+ def test_hilltop_client_timeout_configuration(self, local_test_server):
+ """Test that HilltopClient correctly configures httpx timeout."""
+
+ timeout_value = 15
+ with HilltopClient(
+ base_url=local_test_server["base_url"],
+ hts_endpoint=local_test_server["hts_endpoint"],
+ timeout=timeout_value
+ ) as client:
+ # Verify the timeout is correctly configured in the underlying session
+ assert client.timeout == timeout_value
+ assert client.session.timeout.timeout == timeout_value
+
+ # Make a request to ensure it works
+ result = client.get_status()
+ assert hasattr(result, 'request')
+
+
+class TestAsyncTimeoutBehavior:
+ """Test async client timeout behavior."""
+
+ @pytest.mark.performance_local
+ async def test_async_client_timeout_performance(self, local_test_server):
+ """Test async client timeout performance."""
+
+ # Test with various timeout configurations
+ timeout_configs = [5.0, 10.0, 30.0]
+
+ for timeout in timeout_configs:
+ start_time = time.perf_counter()
+ async with httpx.AsyncClient(
+ base_url=local_test_server["base_url"],
+ timeout=timeout
+ ) as client:
+ tasks = [
+ client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ for _ in range(3)
+ ]
+ results = await asyncio.gather(*tasks)
+ end_time = time.perf_counter()
+
+ # Verify all succeeded
+ assert len(results) == 3
+ for result in results:
+ assert result.status_code == 200
+
+ async_time = end_time - start_time
+ print(f"Async timeout {timeout}s time: {async_time:.4f}s")
\ No newline at end of file
From 840238dd81c1abd11e101f73661cc4648dd8d25c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 16 Sep 2025 02:46:00 +0000
Subject: [PATCH 3/5] Complete httpx performance integration tests
implementation
Co-authored-by: nicmostert <142271462+nicmostert@users.noreply.github.com>
---
README.md | 24 +++
docs/PERFORMANCE_TESTING.md | 188 ++++++++++++++++++
tests/performance/test_connection_features.py | 76 ++++---
3 files changed, 255 insertions(+), 33 deletions(-)
create mode 100644 docs/PERFORMANCE_TESTING.md
diff --git a/README.md b/README.md
index fb42f6b..7062868 100644
--- a/README.md
+++ b/README.md
@@ -660,6 +660,30 @@ This strategy ensures that:
3. Tests remain reliable even when remote APIs are unavailable
4. Response schema validation stays current with actual API behavior
+#### Performance Testing
+
+HURL includes comprehensive performance tests to validate httpx performance features. See [Performance Testing Documentation](docs/PERFORMANCE_TESTING.md) for detailed information.
+
+```bash
+# Run performance tests against local FastAPI test server
+python -m pytest tests/performance/ --performance-local
+
+# Run specific performance test categories
+python -m pytest tests/performance/test_connection_features.py --performance-local
+python -m pytest tests/performance/test_concurrency_features.py --performance-local
+
+# Performance tests are opt-in only and never run by default
+python -m pytest tests/performance/ # These will be skipped without --performance-local
+```
+
+Performance tests validate:
+- Connection keep-alive vs new connections
+- Connection pooling configurations
+- HTTP/1.1 vs HTTP/2 protocol performance
+- Sync vs async client performance
+- Concurrency scaling behavior
+- Timeout and retry configuration effects
+
### Code Style
This project uses:
diff --git a/docs/PERFORMANCE_TESTING.md b/docs/PERFORMANCE_TESTING.md
new file mode 100644
index 0000000..ef6edcc
--- /dev/null
+++ b/docs/PERFORMANCE_TESTING.md
@@ -0,0 +1,188 @@
+# Performance Testing for HURL
+
+This document describes the performance testing infrastructure implemented for HURL to test httpx performance features.
+
+## Overview
+
+HURL now includes comprehensive performance tests that demonstrate and validate the performance impact of various httpx features including:
+
+- Connection keep-alive vs new connections
+- Connection pooling configurations
+- HTTP/1.1 vs HTTP/2 protocol performance
+- Sync vs async client performance
+- Concurrency scaling behavior
+- Timeout and retry configuration effects
+
+## Test Environment Strategies
+
+### 1. Local Test Server (FastAPI)
+- **Purpose**: Performance testing with controlled, local environment
+- **Implementation**: FastAPI-based server serving mock Hilltop XML responses
+- **Configuration**: Configurable delays, error simulation, and fixture serving
+- **Usage**: Enabled with `--performance-local` flag
+
+### 2. Remote Server Testing
+- **Purpose**: Real-world performance validation against internet-accessible APIs
+- **Configuration**: Via environment variables for security
+- **Usage**: Enabled with `--performance-remote` flag (not yet implemented)
+
+### 3. Mocked Responses (Existing)
+- **Purpose**: Correctness validation without network dependency
+- **Implementation**: Existing pytest-httpx fixture cache system
+- **Usage**: Default behavior for CI/CD
+
+## Running Performance Tests
+
+### Prerequisites
+
+Install performance testing dependencies:
+```bash
+pip install pytest-benchmark fastapi uvicorn
+```
+
+### Local Performance Tests
+
+Run performance tests against local FastAPI test server:
+
+```bash
+# Run all local performance tests
+python -m pytest tests/performance/ --performance-local
+
+# Run specific test categories
+python -m pytest tests/performance/test_connection_features.py --performance-local
+python -m pytest tests/performance/test_concurrency_features.py --performance-local
+
+# Run with verbose output
+python -m pytest tests/performance/ --performance-local -v -s
+```
+
+### Configuration
+
+Performance tests can be configured via environment variables:
+
+```bash
+# Local test server configuration
+export TEST_SERVER_PORT=8001 # Server port (default: 8001)
+export TEST_SERVER_DELAY=0.01 # Artificial delay in seconds (default: 0.01)
+export TEST_SERVER_ERROR_RATE=0.0 # Error simulation rate 0.0-1.0 (default: 0.0)
+
+# Remote testing configuration (for future implementation)
+export HILLTOP_PERFORMANCE_BASE_URL=https://your-test-server.com
+export HILLTOP_PERFORMANCE_HTS_ENDPOINT=test.hts
+```
+
+## Test Categories
+
+### Connection Management Tests
+- **File**: `tests/performance/test_connection_features.py`
+- **Features**: Keep-alive connections, connection pooling, pool limits
+- **Example Results**:
+ ```
+ Keep-alive time: 0.1176s
+ New connection time: 0.1233s
+ Performance improvement: 4.6%
+ ```
+
+### Protocol Performance Tests
+- **File**: `tests/performance/test_protocol_features.py`
+- **Features**: HTTP/1.1 vs HTTP/2, protocol negotiation
+- **Metrics**: Request latency, throughput, protocol version validation
+
+### Concurrency Tests
+- **File**: `tests/performance/test_concurrency_features.py`
+- **Features**: Sync vs async clients, concurrent request handling, scaling
+- **Example Results**:
+ ```
+ Concurrency 1: 64.87 RPS
+ Concurrency 2: 140.36 RPS
+ Concurrency 5: 268.32 RPS
+ Concurrency 10: 398.80 RPS
+ ```
+
+### Timeout and Retry Tests
+- **File**: `tests/performance/test_timeout_retry.py`
+- **Features**: Timeout configurations, retry behavior, error handling
+- **Metrics**: Error handling overhead, retry performance impact
+
+## Benchmark Output
+
+Performance tests use `pytest-benchmark` to provide detailed metrics:
+
+```
+Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations
+--------------------------------------------------------------------------------------------------------------------------------------
+test_sequential_requests_with_keep_alive 57.1432 58.3662 57.6593 0.2948 57.6226 0.2820 6;1 17.3433 17 1
+```
+
+## Design Principles
+
+### Opt-in Testing
+- Performance tests **never run by default**
+- Require explicit CLI flags (`--performance-local`, `--performance-remote`)
+- Skipped with clear messages when flags not provided
+
+### CI/CD Integration
+- Only mocked tests run in CI (no network dependencies)
+- Performance tests can be added to CI if fast enough
+- Slow tests marked with `@pytest.mark.slow` for selective exclusion
+
+### Configurability
+- All server URLs, delays, and error rates configurable via environment variables
+- Deterministic behavior (no random values)
+- Sensible defaults for local development
+
+### TDD Approach
+- Tests designed before implementation changes
+- Focus on demonstrating clear performance impact
+- Quantitative metrics over qualitative assessments
+
+## Local Test Server Details
+
+The FastAPI-based local test server:
+
+- **Endpoint**: `http://127.0.0.1:8001/foo.hts`
+- **Health Check**: `http://127.0.0.1:8001/health`
+- **XML Format**: Proper HilltopServer format compatible with response parsers
+- **Fixture Support**: Serves cached fixture data when available
+- **Error Simulation**: Configurable error rates and artificial delays
+
+### Mock Response Example
+
+```xml
+
+
+ Test
+ MockServer-1.0
+ foo.hts
+ 12345
+
+ test.dsn
+ 1
+
+
+```
+
+## Future Enhancements
+
+1. **Remote Performance Testing**: Complete implementation of `--performance-remote` flag
+2. **HTTP/2 Support**: Enhanced protocol testing when server supports HTTP/2
+3. **Historical Benchmarking**: Store and compare performance results over time
+4. **Load Testing**: Stress testing scenarios with high concurrency
+5. **Configuration-based Fixture Mapping**: Replace hardcoded mapping with config files
+
+## Troubleshooting
+
+### Server Startup Issues
+- Check port availability (default 8001)
+- Verify FastAPI and uvicorn installation
+- Check firewall/security restrictions
+
+### Test Failures
+- Ensure performance flags are provided (`--performance-local`)
+- Check environment variable configuration
+- Verify network connectivity for remote tests
+
+### Slow Performance
+- Check `TEST_SERVER_DELAY` configuration
+- Verify system resources aren't constrained
+- Consider reducing test iteration counts for development
\ No newline at end of file
diff --git a/tests/performance/test_connection_features.py b/tests/performance/test_connection_features.py
index 3826458..82657f7 100644
--- a/tests/performance/test_connection_features.py
+++ b/tests/performance/test_connection_features.py
@@ -106,40 +106,34 @@ class TestConnectionPooling:
"""Test connection pooling features."""
@pytest.mark.performance_local
- def test_pooled_vs_non_pooled_requests(self, benchmark, local_test_server):
+ def test_pooled_vs_non_pooled_requests(self, local_test_server):
"""Compare pooled vs non-pooled connection performance."""
- def make_pooled_requests():
- """Make requests using httpx client with connection pooling."""
- with httpx.Client(
- base_url=local_test_server["base_url"],
- limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
- timeout=30.0
- ) as client:
- results = []
- for _ in range(5):
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
- results.append(response)
- return results
+ # Test pooled requests
+ pooled_start = time.perf_counter()
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
+ timeout=30.0
+ ) as client:
+ pooled_results = []
+ for _ in range(5):
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ pooled_results.append(response)
+ pooled_time = time.perf_counter() - pooled_start
- def make_non_pooled_requests():
- """Make requests using httpx with minimal connection limits."""
- with httpx.Client(
- base_url=local_test_server["base_url"],
- limits=httpx.Limits(max_connections=1, max_keepalive_connections=0),
- timeout=30.0
- ) as client:
- results = []
- for _ in range(5):
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
- results.append(response)
- return results
-
- # Benchmark pooled requests
- pooled_results = benchmark.pedantic(make_pooled_requests, rounds=3, iterations=1)
-
- # Benchmark non-pooled requests
- non_pooled_results = benchmark.pedantic(make_non_pooled_requests, rounds=3, iterations=1)
+ # Test non-pooled requests
+ non_pooled_start = time.perf_counter()
+ with httpx.Client(
+ base_url=local_test_server["base_url"],
+ limits=httpx.Limits(max_connections=1, max_keepalive_connections=0),
+ timeout=30.0
+ ) as client:
+ non_pooled_results = []
+ for _ in range(5):
+ response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ non_pooled_results.append(response)
+ non_pooled_time = time.perf_counter() - non_pooled_start
# Verify both approaches work
assert len(pooled_results) == 5
@@ -147,6 +141,13 @@ def make_non_pooled_requests():
for result in pooled_results + non_pooled_results:
assert result.status_code == 200
+
+ print(f"Pooled requests time: {pooled_time:.4f}s")
+ print(f"Non-pooled requests time: {non_pooled_time:.4f}s")
+
+ # Both should complete successfully
+ assert pooled_time > 0
+ assert non_pooled_time > 0
@pytest.mark.performance_local
def test_connection_pool_limits(self, local_test_server):
@@ -205,14 +206,23 @@ def test_hilltop_client_connection_config(self, local_test_server):
timeout=30
) as client:
# Verify the client session has expected configuration
- assert client.session.limits.max_connections == 10 # From client.py
- assert client.session.timeout.timeout == 30
+ assert client.session.timeout.connect == 30
+ assert client.session.timeout.read == 30
assert client.session.follow_redirects is True
# Test that connections work
response = client.get_status()
assert hasattr(response, 'request')
+ # Verify the client timeout configuration
+ assert client.timeout == 30
+
+ print(f"Client timeout: {client.timeout}s")
+ print(f"Session timeout: {client.session.timeout}")
+ print(f"Follow redirects: {client.session.follow_redirects}")
+ print(f"Base URL: {client.base_url}")
+ print(f"HTS Endpoint: {client.hts_endpoint}")
+
@pytest.mark.performance_local
def test_custom_connection_configuration(self, local_test_server):
"""Test custom httpx connection configurations."""
From 712ec21633532e4e5d8b68d6d72887485d46b56e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 16 Sep 2025 04:23:37 +0000
Subject: [PATCH 4/5] Implement httpx performance features: AsyncHilltopClient,
HTTP/2, configurable pooling
Co-authored-by: nicmostert <142271462+nicmostert@users.noreply.github.com>
---
hurl/__init__.py | 4 +
hurl/client.py | 181 +++++++++++++++++-
.../performance/test_concurrency_features.py | 14 +-
3 files changed, 193 insertions(+), 6 deletions(-)
diff --git a/hurl/__init__.py b/hurl/__init__.py
index 44245ee..71c3aed 100644
--- a/hurl/__init__.py
+++ b/hurl/__init__.py
@@ -1,5 +1,9 @@
"""Top-level package for HURL."""
+from .client import HilltopClient, AsyncHilltopClient
+
__author__ = """Nic Mostert"""
__email__ = "nicolas.mostert@horizons.govt.nz"
__version__ = "0.1.0"
+
+__all__ = ["HilltopClient", "AsyncHilltopClient"]
diff --git a/hurl/client.py b/hurl/client.py
index 06dcafd..f935399 100644
--- a/hurl/client.py
+++ b/hurl/client.py
@@ -1,6 +1,8 @@
"""Hilltop Client Module."""
+import asyncio
import os
+from typing import Optional
import httpx
import certifi
@@ -30,14 +32,28 @@ def __init__(
base_url: str | None = None,
hts_endpoint: str | None = None,
timeout: int = 60,
+ max_connections: int = 10,
+ max_keepalive_connections: int = 5,
+ http2: bool = False,
+ verify_ssl: bool = False, # Keep as False for backward compatibility
):
self.base_url = base_url or os.getenv("HILLTOP_BASE_URL")
self.hts_endpoint = hts_endpoint or os.getenv("HILLTOP_HTS_ENDPOINT")
self.timeout = timeout
+ self.max_connections = max_connections
+ self.max_keepalive_connections = max_keepalive_connections
+ self.http2 = http2
+ self.verify_ssl = verify_ssl
+
+ # Create httpx session with configurable options
self.session = httpx.Client(
timeout=httpx.Timeout(timeout=timeout),
- limits=httpx.Limits(max_connections=10),
- verify=False, # TEMPORARY! # Disable SSL verification for testing
+ limits=httpx.Limits(
+ max_connections=max_connections,
+ max_keepalive_connections=max_keepalive_connections
+ ),
+ http2=http2,
+ verify=verify_ssl,
follow_redirects=True,
)
@@ -167,3 +183,164 @@ def __enter__(self):
def __exit__(self, exc_type, exc_value, traceback):
"""Exit the runtime context related to this object."""
self.close()
+
+
+class AsyncHilltopClient:
+ """An async client for interacting with Hilltop Server."""
+
+ def __init__(
+ self,
+ base_url: str | None = None,
+ hts_endpoint: str | None = None,
+ timeout: int = 60,
+ max_connections: int = 10,
+ max_keepalive_connections: int = 5,
+ http2: bool = False,
+ verify_ssl: bool = False,
+ ):
+ self.base_url = base_url or os.getenv("HILLTOP_BASE_URL")
+ self.hts_endpoint = hts_endpoint or os.getenv("HILLTOP_HTS_ENDPOINT")
+ self.timeout = timeout
+ self.max_connections = max_connections
+ self.max_keepalive_connections = max_keepalive_connections
+ self.http2 = http2
+ self.verify_ssl = verify_ssl
+
+ # Create async httpx session
+ self.session = httpx.AsyncClient(
+ timeout=httpx.Timeout(timeout=timeout),
+ limits=httpx.Limits(
+ max_connections=max_connections,
+ max_keepalive_connections=max_keepalive_connections
+ ),
+ http2=http2,
+ verify=verify_ssl,
+ follow_redirects=True,
+ )
+
+ if not self.base_url:
+ raise HilltopConfigError(
+ "Base URL must be provided or set in environment variables."
+ )
+
+ if not self.hts_endpoint:
+ raise HilltopConfigError(
+ "Hilltop HTS endpoint must be provided or set in environment variables."
+ )
+
+ async def _validate_response(self, response: httpx.Response) -> None:
+ """Raise HilltopResponseError if the response is not successful."""
+ print(f"Response status code: {response.status_code}")
+ try:
+ response.raise_for_status()
+ except httpx.HTTPStatusError as e:
+ raise HilltopResponseError(
+ f"HTTP error occurred: {e.response.status_code} - {e.response.text}",
+ url=str(e.request.url),
+ raw_response=e.response.text,
+ ) from e
+
+ async def get_collection_list(self, **kwargs) -> CollectionListResponse:
+ """Fetch the collection list from Hilltop Server."""
+ request = CollectionListRequest(
+ base_url=self.base_url,
+ hts_endpoint=self.hts_endpoint,
+ **kwargs,
+ )
+ response = await self.session.get(request.gen_url())
+ await self._validate_response(response)
+ result = CollectionListResponse.from_xml(response.text)
+ result.request = request
+ return result
+
+ async def get_data(self, **kwargs) -> GetDataResponse:
+ """Fetch data from Hilltop Server."""
+ request = GetDataRequest(
+ base_url=self.base_url,
+ hts_endpoint=self.hts_endpoint,
+ **kwargs,
+ )
+ response = await self.session.get(request.gen_url())
+ await self._validate_response(response)
+ result = GetDataResponse.from_xml(response.text)
+ result.request = request
+ return result
+
+ async def get_measurement_list(self, **kwargs) -> MeasurementListResponse:
+ """Fetch the measurement list from Hilltop Server."""
+ request = MeasurementListRequest(
+ base_url=self.base_url,
+ hts_endpoint=self.hts_endpoint,
+ **kwargs,
+ )
+ print(request.gen_url())
+ response = await self.session.get(request.gen_url())
+ await self._validate_response(response)
+ result = MeasurementListResponse.from_xml(response.text)
+ result.request = request
+ return result
+
+ async def get_site_info(self, **kwargs) -> SiteInfoResponse:
+ """Fetch the site info from Hilltop Server."""
+ request = SiteInfoRequest(
+ base_url=self.base_url,
+ hts_endpoint=self.hts_endpoint,
+ **kwargs,
+ )
+ response = await self.session.get(request.gen_url())
+ await self._validate_response(response)
+ result = SiteInfoResponse.from_xml(response.text)
+ result.request = request
+ return result
+
+ async def get_site_list(self, **kwargs) -> SiteListResponse:
+ """Fetch the site list from Hilltop Server."""
+ request = SiteListRequest(
+ base_url=self.base_url,
+ hts_endpoint=self.hts_endpoint,
+ **kwargs,
+ )
+ response = await self.session.get(request.gen_url())
+ await self._validate_response(response)
+ result = SiteListResponse.from_xml(response.text)
+ result.request = request
+ return result
+
+ async def get_status(self, **kwargs) -> StatusResponse:
+ """Fetch the status from Hilltop Server."""
+ request = StatusRequest(
+ base_url=self.base_url,
+ hts_endpoint=self.hts_endpoint,
+ **kwargs,
+ )
+ print(request.gen_url())
+ response = await self.session.get(request.gen_url())
+ await self._validate_response(response)
+ result = StatusResponse.from_xml(response.text)
+ result.request = request
+ return result
+
+ async def get_time_range(self, **kwargs) -> TimeRangeResponse:
+ """Fetch the TimeRange from Hilltop Server."""
+ request = TimeRangeRequest(
+ base_url=self.base_url,
+ hts_endpoint=self.hts_endpoint,
+ **kwargs,
+ )
+ response = await self.session.get(request.gen_url())
+ await self._validate_response(response)
+ result = TimeRangeResponse.from_xml(response.text)
+ result.request = request
+ return result
+
+ async def close(self):
+ """Close the HTTP session."""
+ await self.session.aclose()
+
+ async def __aenter__(self):
+ """Enter the async runtime context related to this object."""
+ return self
+
+ async def __aexit__(self, exc_type, exc_value, traceback):
+ """Exit the async runtime context related to this object."""
+ await self.close()
diff --git a/tests/performance/test_concurrency_features.py b/tests/performance/test_concurrency_features.py
index 218e4d1..b77c315 100644
--- a/tests/performance/test_concurrency_features.py
+++ b/tests/performance/test_concurrency_features.py
@@ -58,10 +58,13 @@ async def make_async_requests():
results.append(response)
return results
+ def run_async_test():
+ """Wrapper to run the async test."""
+ return asyncio.run(make_async_requests())
+
# Use pytest-benchmark's async support
results = benchmark.pedantic(
- asyncio.run,
- args=[make_async_requests()],
+ run_async_test,
rounds=3,
iterations=1
)
@@ -165,9 +168,12 @@ async def make_async_concurrent_requests():
results = await asyncio.gather(*tasks)
return results
+ def run_async_test():
+ """Wrapper to run the async test."""
+ return asyncio.run(make_async_concurrent_requests())
+
results = benchmark.pedantic(
- asyncio.run,
- args=[make_async_concurrent_requests()],
+ run_async_test,
rounds=3,
iterations=1
)
From f4a43fe2a601d4d33757ebb21f48ceea8e9fda6e Mon Sep 17 00:00:00 2001
From: nicmostert
Date: Wed, 17 Sep 2025 08:12:24 +1200
Subject: [PATCH 5/5] Added missing dependencies, formatted copilots slop with
black and isort
---
poetry.lock | 172 ++++++++++++-
pyproject.toml | 9 +-
tests/performance/__init__.py | 2 +-
tests/performance/local_server.py | 93 +++----
.../performance/test_concurrency_features.py | 222 +++++++++--------
tests/performance/test_connection_features.py | 130 +++++-----
tests/performance/test_protocol_features.py | 115 +++++----
tests/performance/test_timeout_retry.py | 230 ++++++++++--------
8 files changed, 597 insertions(+), 376 deletions(-)
diff --git a/poetry.lock b/poetry.lock
index e4759a0..f38420e 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -44,6 +44,21 @@ files = [
{file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"},
]
+[[package]]
+name = "click"
+version = "8.2.1"
+description = "Composable command line interface toolkit"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"},
+ {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
[[package]]
name = "colorama"
version = "0.4.6"
@@ -51,12 +66,34 @@ description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main"]
-markers = "sys_platform == \"win32\""
+markers = "sys_platform == \"win32\" or platform_system == \"Windows\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
+[[package]]
+name = "fastapi"
+version = "0.116.1"
+description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565"},
+ {file = "fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143"},
+]
+
+[package.dependencies]
+pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
+starlette = ">=0.40.0,<0.48.0"
+typing-extensions = ">=4.8.0"
+
+[package.extras]
+all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
+standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
+standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -69,6 +106,34 @@ files = [
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
]
+[[package]]
+name = "h2"
+version = "4.3.0"
+description = "Pure-Python HTTP/2 protocol implementation"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd"},
+ {file = "h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1"},
+]
+
+[package.dependencies]
+hpack = ">=4.1,<5"
+hyperframe = ">=6.1,<7"
+
+[[package]]
+name = "hpack"
+version = "4.1.0"
+description = "Pure-Python HPACK header encoding"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496"},
+ {file = "hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca"},
+]
+
[[package]]
name = "httpcore"
version = "1.0.9"
@@ -116,6 +181,18 @@ http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"]
+[[package]]
+name = "hyperframe"
+version = "6.1.0"
+description = "Pure-Python HTTP/2 framing"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"},
+ {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"},
+]
+
[[package]]
name = "idna"
version = "3.10"
@@ -485,6 +562,18 @@ files = [
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
+[[package]]
+name = "py-cpuinfo"
+version = "9.0.0"
+description = "Get CPU info with pure Python"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690"},
+ {file = "py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5"},
+]
+
[[package]]
name = "pydantic"
version = "2.11.9"
@@ -656,6 +745,47 @@ pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
+[[package]]
+name = "pytest-asyncio"
+version = "1.2.0"
+description = "Pytest support for asyncio"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99"},
+ {file = "pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57"},
+]
+
+[package.dependencies]
+pytest = ">=8.2,<9"
+typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""}
+
+[package.extras]
+docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
+testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
+
+[[package]]
+name = "pytest-benchmark"
+version = "5.1.0"
+description = "A ``pytest`` fixture for benchmarking code. It will group the tests into rounds that are calibrated to the chosen timer."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "pytest-benchmark-5.1.0.tar.gz", hash = "sha256:9ea661cdc292e8231f7cd4c10b0319e56a2118e2c09d9f50e1b3d150d2aca105"},
+ {file = "pytest_benchmark-5.1.0-py3-none-any.whl", hash = "sha256:922de2dfa3033c227c96da942d1878191afa135a29485fb942e85dff1c592c89"},
+]
+
+[package.dependencies]
+py-cpuinfo = "*"
+pytest = ">=8.1"
+
+[package.extras]
+aspect = ["aspectlib"]
+elasticsearch = ["elasticsearch"]
+histogram = ["pygal", "pygaljs", "setuptools"]
+
[[package]]
name = "pytest-httpx"
version = "0.35.0"
@@ -822,6 +952,25 @@ files = [
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
]
+[[package]]
+name = "starlette"
+version = "0.47.3"
+description = "The little ASGI library that shines."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51"},
+ {file = "starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9"},
+]
+
+[package.dependencies]
+anyio = ">=3.6.2,<5"
+typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""}
+
+[package.extras]
+full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
+
[[package]]
name = "typing-extensions"
version = "4.15.0"
@@ -861,6 +1010,25 @@ files = [
{file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"},
]
+[[package]]
+name = "uvicorn"
+version = "0.35.0"
+description = "The lightning-fast ASGI server."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"},
+ {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"},
+]
+
+[package.dependencies]
+click = ">=7.0"
+h11 = ">=0.8"
+
+[package.extras]
+standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"]
+
[[package]]
name = "xmltodict"
version = "1.0.0"
@@ -876,4 +1044,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = ">=3.11"
-content-hash = "ba73f48dd659f82db52bed7f6ef4ec824cd0dfba142544891edc3a3f09511325"
+content-hash = "483e0847d2999b16824e6f7e41ebf43bdaf15eb4137b63a06e0985912f74039c"
diff --git a/pyproject.toml b/pyproject.toml
index 109edda..9e22e1c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,7 +20,13 @@ dependencies = [
"isodate (>=0.7.2,<0.8.0)",
"xmltodict (>=1.0.0,<2.0.0)",
"certifi (>=2025.8.3,<2026.0.0)",
- "pyyaml (>=6.0.2,<7.0.0)"
+ "pyyaml (>=6.0.2,<7.0.0)",
+ "fastapi (>=0.116.1,<0.117.0)",
+ "uvicorn (>=0.35.0,<0.36.0)",
+ "pytest-benchmark (>=5.1.0,<6.0.0)",
+ "anyio (>=4.10.0,<5.0.0)",
+ "pytest-asyncio (>=1.2.0,<2.0.0)",
+ "h2 (>=4.3.0,<5.0.0)"
]
@@ -29,6 +35,7 @@ requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.pytest.ini_options]
+asyncio_mode = "auto"
markers = [
"slow: marks tests as slow (deselect as '-m \"not slow\"')",
"remote: marks tests as remote (deselect as '-m \"not remote\"')",
diff --git a/tests/performance/__init__.py b/tests/performance/__init__.py
index 672159e..47dca00 100644
--- a/tests/performance/__init__.py
+++ b/tests/performance/__init__.py
@@ -1 +1 @@
-"""Performance testing module for HURL httpx features."""
\ No newline at end of file
+"""Performance testing module for HURL httpx features."""
diff --git a/tests/performance/local_server.py b/tests/performance/local_server.py
index 9e39467..44ff85f 100644
--- a/tests/performance/local_server.py
+++ b/tests/performance/local_server.py
@@ -11,27 +11,28 @@
from pathlib import Path
from typing import Optional
+import uvicorn
from fastapi import FastAPI, HTTPException, Response
from fastapi.responses import PlainTextResponse
-import uvicorn
class LocalTestServer:
"""FastAPI-based local test server for performance testing."""
-
+
def __init__(
- self,
- host: str = "127.0.0.1",
+ self,
+ host: str = "127.0.0.1",
port: int = 8000,
fixture_cache_path: Optional[Path] = None,
default_delay: float = 0.0,
- error_rate: float = 0.0
+ error_rate: float = 0.0,
):
"""Initialize the local test server.
-
+
Args:
+ ----
host: Host to bind to (default: 127.0.0.1)
- port: Port to bind to (default: 8000)
+ port: Port to bind to (default: 8000)
fixture_cache_path: Path to fixture cache directory
default_delay: Default artificial delay in seconds
error_rate: Rate of errors to simulate (0.0-1.0)
@@ -40,26 +41,26 @@ def __init__(
self.port = port
self.default_delay = default_delay
self.error_rate = error_rate
-
+
# Set fixture cache path
if fixture_cache_path is None:
self.fixture_cache_path = Path(__file__).parent.parent / "fixture_cache"
else:
self.fixture_cache_path = fixture_cache_path
-
+
self.app = FastAPI(
title="HURL Performance Test Server",
description="Local test server for httpx performance testing",
- version="1.0.0"
+ version="1.0.0",
)
-
+
# Hardcoded fixture mapping (can be extended to config-based later)
self.fixture_mapping = {
# StatusRequest endpoint
"/foo.hts": {
"Service=Hilltop&Request=Status": "status/response.xml",
},
- # SiteListRequest endpoint
+ # SiteListRequest endpoint
"/foo.hts": {
"Service=Hilltop&Request=SiteList": "site_list/all_response.xml",
},
@@ -72,38 +73,37 @@ def __init__(
"Service=Hilltop&Request=GetData": "get_data/basic_response.xml",
},
}
-
+
self._setup_routes()
-
+
def _setup_routes(self):
"""Setup FastAPI routes."""
-
+
@self.app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy", "server": "hurl-performance-test"}
-
+
@self.app.get("/foo.hts")
async def hilltop_endpoint(service: str = None, request: str = None):
- """Main Hilltop-compatible endpoint."""
-
+ """Provide Main Hilltop-compatible endpoint."""
# Simulate artificial delay if configured
if self.default_delay > 0:
await asyncio.sleep(self.default_delay)
-
+
# Simulate errors if configured
if self.error_rate > 0 and time.time() % 1.0 < self.error_rate:
raise HTTPException(status_code=500, detail="Simulated server error")
-
+
# Handle case where parameters might be case-insensitive
if not service:
service = "Hilltop" # Default service
if not request:
- request = "Status" # Default request
-
+ request = "Status" # Default request
+
# Create query key for fixture mapping
query_key = f"Service={service}&Request={request}"
-
+
# Find matching fixture
fixture_file = None
if query_key in self.fixture_mapping.get("/foo.hts", {}):
@@ -118,11 +118,11 @@ async def hilltop_endpoint(service: str = None, request: str = None):
fixture_file = "measurement_list/all_response.xml"
elif request == "GetData":
fixture_file = "get_data/basic_response.xml"
-
+
if not fixture_file:
# Fallback to default response
fixture_file = "status/response.xml"
-
+
# Load fixture content
fixture_path = self.fixture_cache_path / fixture_file
if not fixture_path.exists():
@@ -138,17 +138,11 @@ async def hilltop_endpoint(service: str = None, request: str = None):
1
"""
- return PlainTextResponse(
- content=default_xml,
- media_type="text/xml"
- )
-
+ return PlainTextResponse(content=default_xml, media_type="text/xml")
+
try:
xml_content = fixture_path.read_text(encoding="utf-8")
- return PlainTextResponse(
- content=xml_content,
- media_type="text/xml"
- )
+ return PlainTextResponse(content=xml_content, media_type="text/xml")
except Exception as e:
# Fallback to default XML on error
default_xml = f"""
@@ -162,11 +156,8 @@ async def hilltop_endpoint(service: str = None, request: str = None):
1
"""
- return PlainTextResponse(
- content=default_xml,
- media_type="text/xml"
- )
-
+ return PlainTextResponse(content=default_xml, media_type="text/xml")
+
def run(self, **kwargs):
"""Run the server."""
# Set default values but allow override
@@ -176,17 +167,13 @@ def run(self, **kwargs):
"log_level": "warning", # Reduce noise during testing
}
run_kwargs.update(kwargs)
-
+
uvicorn.run(self.app, **run_kwargs)
-
+
async def run_async(self, **kwargs):
"""Run the server asynchronously."""
config = uvicorn.Config(
- self.app,
- host=self.host,
- port=self.port,
- log_level="warning",
- **kwargs
+ self.app, host=self.host, port=self.port, log_level="warning", **kwargs
)
server = uvicorn.Server(config)
await server.serve()
@@ -196,14 +183,11 @@ def create_test_server(
host: str = "127.0.0.1",
port: int = 8000,
delay: float = 0.0,
- error_rate: float = 0.0
+ error_rate: float = 0.0,
) -> LocalTestServer:
- """Factory function to create a test server with specified configuration."""
+ """Create a test server with specified configuration (Factory function)."""
return LocalTestServer(
- host=host,
- port=port,
- default_delay=delay,
- error_rate=error_rate
+ host=host, port=port, default_delay=delay, error_rate=error_rate
)
@@ -212,8 +196,9 @@ def create_test_server(
delay = float(os.getenv("TEST_SERVER_DELAY", "0.0"))
error_rate = float(os.getenv("TEST_SERVER_ERROR_RATE", "0.0"))
port = int(os.getenv("TEST_SERVER_PORT", "8000"))
-
+
server = create_test_server(delay=delay, error_rate=error_rate, port=port)
print(f"Starting HURL Performance Test Server on http://127.0.0.1:{port}")
print(f"Configuration: delay={delay}s, error_rate={error_rate}")
- server.run()
\ No newline at end of file
+ server.run()
+
diff --git a/tests/performance/test_concurrency_features.py b/tests/performance/test_concurrency_features.py
index b77c315..dde354b 100644
--- a/tests/performance/test_concurrency_features.py
+++ b/tests/performance/test_concurrency_features.py
@@ -18,101 +18,101 @@
class TestSyncVsAsyncPerformance:
"""Compare synchronous vs asynchronous client performance."""
-
+
@pytest.mark.performance_local
def test_sync_sequential_requests(self, benchmark, local_test_server):
"""Test synchronous sequential request performance."""
-
+
def make_sync_requests():
"""Make sequential requests using sync client."""
with httpx.Client(
- base_url=local_test_server["base_url"],
- timeout=30.0
+ base_url=local_test_server["base_url"], timeout=30.0
) as client:
results = []
for _ in range(5):
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
results.append(response)
return results
-
+
results = benchmark(make_sync_requests)
-
+
# Verify all requests succeeded
assert len(results) == 5
for result in results:
assert result.status_code == 200
-
+
@pytest.mark.performance_local
def test_async_sequential_requests(self, benchmark, local_test_server):
"""Test asynchronous sequential request performance."""
-
+
async def make_async_requests():
"""Make sequential requests using async client."""
async with httpx.AsyncClient(
- base_url=local_test_server["base_url"],
- timeout=30.0
+ base_url=local_test_server["base_url"], timeout=30.0
) as client:
results = []
for _ in range(5):
- response = await client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = await client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
results.append(response)
return results
-
+
def run_async_test():
"""Wrapper to run the async test."""
return asyncio.run(make_async_requests())
-
+
# Use pytest-benchmark's async support
- results = benchmark.pedantic(
- run_async_test,
- rounds=3,
- iterations=1
- )
-
+ results = benchmark.pedantic(run_async_test, rounds=3, iterations=1)
+
# Verify all requests succeeded
assert len(results) == 5
for result in results:
assert result.status_code == 200
-
+
@pytest.mark.performance_local
def test_sync_vs_async_comparison(self, local_test_server):
"""Compare sync vs async performance for the same workload."""
-
+
# Sync performance
start_time = time.perf_counter()
with httpx.Client(
- base_url=local_test_server["base_url"],
- timeout=30.0
+ base_url=local_test_server["base_url"], timeout=30.0
) as client:
sync_results = []
for _ in range(10):
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
sync_results.append(response)
sync_time = time.perf_counter() - start_time
-
+
# Async performance (sequential)
async def async_sequential():
async with httpx.AsyncClient(
- base_url=local_test_server["base_url"],
- timeout=30.0
+ base_url=local_test_server["base_url"], timeout=30.0
) as client:
async_results = []
for _ in range(10):
- response = await client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = await client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
async_results.append(response)
return async_results
-
+
start_time = time.perf_counter()
async_results = asyncio.run(async_sequential())
async_time = time.perf_counter() - start_time
-
+
# Verify both approaches work
assert len(sync_results) == 10
assert len(async_results) == 10
-
+
for result in sync_results + async_results:
assert result.status_code == 200
-
+
# Record performance metrics
print(f"Sync sequential time: {sync_time:.4f}s")
print(f"Async sequential time: {async_time:.4f}s")
@@ -122,155 +122,162 @@ async def async_sequential():
class TestConcurrentRequests:
"""Test concurrent request performance."""
-
+
@pytest.mark.performance_local
def test_sync_concurrent_with_threads(self, benchmark, local_test_server):
"""Test sync client with thread-based concurrency."""
-
+
def make_single_request():
"""Make a single request."""
with httpx.Client(
- base_url=local_test_server["base_url"],
- timeout=30.0
+ base_url=local_test_server["base_url"], timeout=30.0
) as client:
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
return response
-
+
def make_concurrent_sync_requests():
"""Make concurrent requests using ThreadPoolExecutor."""
with ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(make_single_request) for _ in range(10)]
results = [future.result() for future in futures]
return results
-
+
results = benchmark(make_concurrent_sync_requests)
-
+
# Verify all requests succeeded
assert len(results) == 10
for result in results:
assert result.status_code == 200
-
+
@pytest.mark.performance_local
def test_async_concurrent_requests(self, benchmark, local_test_server):
"""Test async client with asyncio concurrency."""
-
+
async def make_async_concurrent_requests():
"""Make concurrent requests using asyncio.gather."""
async with httpx.AsyncClient(
base_url=local_test_server["base_url"],
limits=httpx.Limits(max_connections=10),
- timeout=30.0
+ timeout=30.0,
) as client:
tasks = [
- client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
for _ in range(10)
]
results = await asyncio.gather(*tasks)
return results
-
+
def run_async_test():
"""Wrapper to run the async test."""
return asyncio.run(make_async_concurrent_requests())
-
- results = benchmark.pedantic(
- run_async_test,
- rounds=3,
- iterations=1
- )
-
+
+ results = benchmark.pedantic(run_async_test, rounds=3, iterations=1)
+
# Verify all requests succeeded
assert len(results) == 10
for result in results:
assert result.status_code == 200
-
+
@pytest.mark.performance_local
def test_concurrency_scaling(self, local_test_server):
"""Test how performance scales with concurrency level."""
-
+
async def test_concurrency_level(concurrency):
"""Test performance at a specific concurrency level."""
async with httpx.AsyncClient(
base_url=local_test_server["base_url"],
limits=httpx.Limits(max_connections=concurrency),
- timeout=30.0
+ timeout=30.0,
) as client:
start_time = time.perf_counter()
tasks = [
- client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
for _ in range(concurrency * 2) # 2x requests vs connections
]
results = await asyncio.gather(*tasks)
end_time = time.perf_counter()
-
+
return {
- 'concurrency': concurrency,
- 'requests': len(results),
- 'time': end_time - start_time,
- 'rps': len(results) / (end_time - start_time),
- 'success': all(r.status_code == 200 for r in results)
+ "concurrency": concurrency,
+ "requests": len(results),
+ "time": end_time - start_time,
+ "rps": len(results) / (end_time - start_time),
+ "success": all(r.status_code == 200 for r in results),
}
-
+
# Test different concurrency levels
concurrency_levels = [1, 2, 5, 10]
results = []
-
+
for level in concurrency_levels:
result = asyncio.run(test_concurrency_level(level))
results.append(result)
-
+
# Verify all tests succeeded
for result in results:
- assert result['success']
- print(f"Concurrency {result['concurrency']}: {result['rps']:.2f} RPS ({result['time']:.4f}s for {result['requests']} requests)")
-
+ assert result["success"]
+ print(
+ f"Concurrency {result['concurrency']}: {result['rps']:.2f} RPS ({result['time']:.4f}s for {result['requests']} requests)"
+ )
+
# Check that concurrency generally improves performance
# (though in test environment this might not always be true)
- assert all(r['rps'] > 0 for r in results)
+ assert all(r["rps"] > 0 for r in results)
class TestAsyncContextManagement:
"""Test async context manager performance."""
-
+
@pytest.mark.performance_local
async def test_async_context_manager_reuse(self, local_test_server):
"""Test reusing async client context manager."""
-
+
async with httpx.AsyncClient(
- base_url=local_test_server["base_url"],
- timeout=30.0
+ base_url=local_test_server["base_url"], timeout=30.0
) as client:
# Multiple requests within same context
start_time = time.perf_counter()
results = []
for _ in range(5):
- response = await client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = await client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
results.append(response)
end_time = time.perf_counter()
-
+
# Verify all succeeded
assert len(results) == 5
for result in results:
assert result.status_code == 200
-
+
reuse_time = end_time - start_time
print(f"Context reuse time: {reuse_time:.4f}s")
-
+
# Compare with creating new context for each request
start_time = time.perf_counter()
new_context_results = []
for _ in range(5):
async with httpx.AsyncClient(
- base_url=local_test_server["base_url"],
- timeout=30.0
+ base_url=local_test_server["base_url"], timeout=30.0
) as client:
- response = await client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = await client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
new_context_results.append(response)
end_time = time.perf_counter()
-
+
new_context_time = end_time - start_time
print(f"New context time: {new_context_time:.4f}s")
- print(f"Context reuse improvement: {((new_context_time - reuse_time) / new_context_time * 100):.1f}%")
-
+ print(
+ f"Context reuse improvement: {((new_context_time - reuse_time) / new_context_time * 100):.1f}%"
+ )
+
# Verify both approaches work
assert len(new_context_results) == 5
for result in new_context_results:
@@ -279,88 +286,91 @@ async def test_async_context_manager_reuse(self, local_test_server):
class TestHilltopClientConcurrency:
"""Test HilltopClient in concurrent scenarios."""
-
+
@pytest.mark.performance_local
def test_hilltop_client_thread_safety(self, local_test_server):
"""Test HilltopClient thread safety (though it's not designed for it)."""
-
+
# Note: HilltopClient is not designed to be thread-safe
# This test demonstrates the recommended pattern: one client per thread
-
+
def make_requests_with_own_client():
"""Each thread creates its own client."""
with HilltopClient(
base_url=local_test_server["base_url"],
hts_endpoint=local_test_server["hts_endpoint"],
- timeout=30
+ timeout=30,
) as client:
results = []
for _ in range(3):
result = client.get_status()
results.append(result)
return results
-
+
# Use thread pool with separate clients
with ThreadPoolExecutor(max_workers=3) as executor:
futures = [executor.submit(make_requests_with_own_client) for _ in range(3)]
all_results = [future.result() for future in futures]
-
+
# Verify all threads succeeded
assert len(all_results) == 3
for thread_results in all_results:
assert len(thread_results) == 3
for result in thread_results:
- assert hasattr(result, 'request')
-
+ assert hasattr(result, "request")
+
@pytest.mark.performance_local
def test_multiple_hilltop_clients(self, local_test_server):
"""Test performance with multiple HilltopClient instances."""
-
+
start_time = time.perf_counter()
results = []
-
+
# Create multiple clients and use them
for i in range(5):
with HilltopClient(
base_url=local_test_server["base_url"],
hts_endpoint=local_test_server["hts_endpoint"],
- timeout=30
+ timeout=30,
) as client:
result = client.get_status()
results.append(result)
-
+
end_time = time.perf_counter()
-
+
# Verify all succeeded
assert len(results) == 5
for result in results:
- assert hasattr(result, 'request')
-
+ assert hasattr(result, "request")
+
multiple_clients_time = end_time - start_time
print(f"Multiple clients time: {multiple_clients_time:.4f}s")
print(f"Multiple clients RPS: {5/multiple_clients_time:.2f}")
-
+
# Compare with single client reuse
start_time = time.perf_counter()
with HilltopClient(
base_url=local_test_server["base_url"],
hts_endpoint=local_test_server["hts_endpoint"],
- timeout=30
+ timeout=30,
) as client:
single_client_results = []
for _ in range(5):
result = client.get_status()
single_client_results.append(result)
end_time = time.perf_counter()
-
+
single_client_time = end_time - start_time
print(f"Single client time: {single_client_time:.4f}s")
print(f"Single client RPS: {5/single_client_time:.2f}")
-
+
# Verify both approaches work
assert len(single_client_results) == 5
for result in single_client_results:
- assert hasattr(result, 'request')
-
+ assert hasattr(result, "request")
+
# Single client reuse should typically be faster
- print(f"Single client improvement: {((multiple_clients_time - single_client_time) / multiple_clients_time * 100):.1f}%")
\ No newline at end of file
+ print(
+ f"Single client improvement: {((multiple_clients_time - single_client_time) / multiple_clients_time * 100):.1f}%"
+ )
+
diff --git a/tests/performance/test_connection_features.py b/tests/performance/test_connection_features.py
index 82657f7..2a58bb3 100644
--- a/tests/performance/test_connection_features.py
+++ b/tests/performance/test_connection_features.py
@@ -18,11 +18,13 @@
class TestConnectionKeepAlive:
"""Test connection keep-alive functionality."""
-
+
@pytest.mark.performance_local
- def test_sequential_requests_with_keep_alive(self, benchmark, performance_local_client):
+ def test_sequential_requests_with_keep_alive(
+ self, benchmark, performance_local_client
+ ):
"""Test performance of sequential requests with keep-alive enabled."""
-
+
def make_requests():
"""Make multiple sequential requests using the same client session."""
results = []
@@ -30,19 +32,19 @@ def make_requests():
response = performance_local_client.get_status()
results.append(response)
return results
-
+
# Benchmark with keep-alive (default httpx behavior)
results = benchmark(make_requests)
-
+
# Verify all requests succeeded
assert len(results) == 5
for result in results:
- assert hasattr(result, 'request')
-
+ assert hasattr(result, "request")
+
@pytest.mark.performance_local
def test_sequential_requests_without_keep_alive(self, benchmark, local_test_server):
"""Test performance of sequential requests without keep-alive."""
-
+
def make_requests_new_client():
"""Make multiple requests, creating a new client for each."""
results = []
@@ -51,51 +53,54 @@ def make_requests_new_client():
with HilltopClient(
base_url=local_test_server["base_url"],
hts_endpoint=local_test_server["hts_endpoint"],
- timeout=30
+ timeout=30,
) as client:
result = client.get_status()
results.append(result)
return results
-
+
# Benchmark without keep-alive (new client each time)
results = benchmark(make_requests_new_client)
-
+
# Verify all requests succeeded
assert len(results) == 5
for result in results:
- assert hasattr(result, 'request')
+ assert hasattr(result, "request")
@pytest.mark.performance_local
def test_keep_alive_vs_new_connections_comparison(self, local_test_server):
"""Compare keep-alive vs new connections performance."""
-
+
# Test with keep-alive (reuse connection)
start_time = time.perf_counter()
with HilltopClient(
base_url=local_test_server["base_url"],
hts_endpoint=local_test_server["hts_endpoint"],
- timeout=30
+ timeout=30,
) as client:
for _ in range(10):
client.get_status()
keep_alive_time = time.perf_counter() - start_time
-
+
# Test without keep-alive (new connection each time)
start_time = time.perf_counter()
for _ in range(10):
with HilltopClient(
base_url=local_test_server["base_url"],
hts_endpoint=local_test_server["hts_endpoint"],
- timeout=30
+ timeout=30,
) as client:
client.get_status()
new_connection_time = time.perf_counter() - start_time
-
- # Keep-alive should be faster (but allow some tolerance for test environment variability)
+
+ # Keep-alive should be faster
+ # (but allow some tolerance for test environment variability)
print(f"Keep-alive time: {keep_alive_time:.4f}s")
- print(f"New connection time: {new_connection_time:.4f}s")
- print(f"Performance improvement: {((new_connection_time - keep_alive_time) / new_connection_time * 100):.1f}%")
-
+ print(f"New connection time: {new_connection_time:.4f}s")
+ print(
+ f"Performance improvement: {((new_connection_time - keep_alive_time) / new_connection_time * 100):.1f}%"
+ )
+
# Keep-alive should typically be faster, but in test environments this might be minimal
# So we just verify both approaches work and record the metrics
assert keep_alive_time > 0
@@ -104,90 +109,98 @@ def test_keep_alive_vs_new_connections_comparison(self, local_test_server):
class TestConnectionPooling:
"""Test connection pooling features."""
-
+
@pytest.mark.performance_local
def test_pooled_vs_non_pooled_requests(self, local_test_server):
"""Compare pooled vs non-pooled connection performance."""
-
+
# Test pooled requests
pooled_start = time.perf_counter()
with httpx.Client(
base_url=local_test_server["base_url"],
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
- timeout=30.0
+ timeout=30.0,
) as client:
pooled_results = []
for _ in range(5):
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
pooled_results.append(response)
pooled_time = time.perf_counter() - pooled_start
-
+
# Test non-pooled requests
non_pooled_start = time.perf_counter()
with httpx.Client(
- base_url=local_test_server["base_url"],
+ base_url=local_test_server["base_url"],
limits=httpx.Limits(max_connections=1, max_keepalive_connections=0),
- timeout=30.0
+ timeout=30.0,
) as client:
non_pooled_results = []
for _ in range(5):
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
non_pooled_results.append(response)
non_pooled_time = time.perf_counter() - non_pooled_start
-
+
# Verify both approaches work
assert len(pooled_results) == 5
assert len(non_pooled_results) == 5
-
+
for result in pooled_results + non_pooled_results:
assert result.status_code == 200
-
+
print(f"Pooled requests time: {pooled_time:.4f}s")
print(f"Non-pooled requests time: {non_pooled_time:.4f}s")
-
+
# Both should complete successfully
assert pooled_time > 0
assert non_pooled_time > 0
- @pytest.mark.performance_local
+ @pytest.mark.performance_local
def test_connection_pool_limits(self, local_test_server):
"""Test behavior with different connection pool limits."""
-
+
# Test with small pool
small_pool_times = []
with httpx.Client(
base_url=local_test_server["base_url"],
limits=httpx.Limits(max_connections=2, max_keepalive_connections=1),
- timeout=30.0
+ timeout=30.0,
) as client:
for _ in range(5):
start = time.perf_counter()
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
end = time.perf_counter()
small_pool_times.append(end - start)
assert response.status_code == 200
-
- # Test with large pool
+
+ # Test with large pool
large_pool_times = []
with httpx.Client(
base_url=local_test_server["base_url"],
limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
- timeout=30.0
+ timeout=30.0,
) as client:
for _ in range(5):
start = time.perf_counter()
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
end = time.perf_counter()
large_pool_times.append(end - start)
assert response.status_code == 200
-
+
# Record metrics for analysis
avg_small = sum(small_pool_times) / len(small_pool_times)
avg_large = sum(large_pool_times) / len(large_pool_times)
-
+
print(f"Small pool average time: {avg_small:.4f}s")
print(f"Large pool average time: {avg_large:.4f}s")
-
+
# Both should complete successfully
assert avg_small > 0
assert avg_large > 0
@@ -195,51 +208,54 @@ def test_connection_pool_limits(self, local_test_server):
class TestConnectionConfiguration:
"""Test different httpx connection configurations."""
-
+
@pytest.mark.performance_local
def test_hilltop_client_connection_config(self, local_test_server):
"""Test HilltopClient's default connection configuration."""
-
+
with HilltopClient(
base_url=local_test_server["base_url"],
hts_endpoint=local_test_server["hts_endpoint"],
- timeout=30
+ timeout=30,
) as client:
# Verify the client session has expected configuration
assert client.session.timeout.connect == 30
assert client.session.timeout.read == 30
assert client.session.follow_redirects is True
-
+
# Test that connections work
response = client.get_status()
- assert hasattr(response, 'request')
-
+ assert hasattr(response, "request")
+
# Verify the client timeout configuration
assert client.timeout == 30
-
+
print(f"Client timeout: {client.timeout}s")
print(f"Session timeout: {client.session.timeout}")
print(f"Follow redirects: {client.session.follow_redirects}")
print(f"Base URL: {client.base_url}")
print(f"HTS Endpoint: {client.hts_endpoint}")
-
+
@pytest.mark.performance_local
def test_custom_connection_configuration(self, local_test_server):
"""Test custom httpx connection configurations."""
-
+
# Test various configurations
configs = [
{"max_connections": 5, "max_keepalive_connections": 2},
{"max_connections": 20, "max_keepalive_connections": 10},
{"max_connections": 1, "max_keepalive_connections": 0},
]
-
+
for config in configs:
with httpx.Client(
base_url=local_test_server["base_url"],
limits=httpx.Limits(**config),
- timeout=30.0
+ timeout=30.0,
) as client:
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
assert response.status_code == 200
- assert "Test" in response.text # Basic XML validation
\ No newline at end of file
+ assert "Test" in response.text # Basic XML validation
+
diff --git a/tests/performance/test_protocol_features.py b/tests/performance/test_protocol_features.py
index 7af1656..49afb4d 100644
--- a/tests/performance/test_protocol_features.py
+++ b/tests/performance/test_protocol_features.py
@@ -17,104 +17,112 @@
class TestHTTPProtocolPerformance:
"""Test HTTP protocol performance characteristics."""
-
+
@pytest.mark.performance_local
def test_http1_sequential_requests(self, benchmark, local_test_server):
"""Test HTTP/1.1 sequential request performance."""
-
+
def make_http1_requests():
"""Make requests using HTTP/1.1 explicitly."""
with httpx.Client(
base_url=local_test_server["base_url"],
http2=False, # Force HTTP/1.1
limits=httpx.Limits(max_connections=10),
- timeout=30.0
+ timeout=30.0,
) as client:
results = []
for _ in range(5):
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
results.append(response)
return results
-
+
results = benchmark(make_http1_requests)
-
+
# Verify all requests succeeded
assert len(results) == 5
for result in results:
assert result.status_code == 200
# Verify we're using HTTP/1.1
assert result.http_version == "HTTP/1.1"
-
- @pytest.mark.performance_local
+
+ @pytest.mark.performance_local
def test_http2_sequential_requests(self, benchmark, local_test_server):
"""Test HTTP/2 sequential request performance."""
-
+
def make_http2_requests():
"""Make requests using HTTP/2 if available."""
with httpx.Client(
base_url=local_test_server["base_url"],
http2=True, # Enable HTTP/2
limits=httpx.Limits(max_connections=10),
- timeout=30.0
+ timeout=30.0,
) as client:
results = []
for _ in range(5):
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
results.append(response)
return results
-
+
results = benchmark(make_http2_requests)
-
+
# Verify all requests succeeded
assert len(results) == 5
for result in results:
assert result.status_code == 200
# Note: Local FastAPI server may not support HTTP/2, so we don't assert version
-
+
@pytest.mark.performance_local
def test_protocol_comparison(self, local_test_server):
"""Compare HTTP/1.1 vs HTTP/2 performance characteristics."""
-
+
# Test HTTP/1.1 performance
start_time = time.perf_counter()
with httpx.Client(
base_url=local_test_server["base_url"],
http2=False,
limits=httpx.Limits(max_connections=5),
- timeout=30.0
+ timeout=30.0,
) as client:
http1_responses = []
for _ in range(10):
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
http1_responses.append(response)
http1_time = time.perf_counter() - start_time
-
+
# Test HTTP/2 performance
start_time = time.perf_counter()
with httpx.Client(
- base_url=local_test_server["base_url"],
+ base_url=local_test_server["base_url"],
http2=True,
limits=httpx.Limits(max_connections=5),
- timeout=30.0
+ timeout=30.0,
) as client:
http2_responses = []
for _ in range(10):
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
http2_responses.append(response)
http2_time = time.perf_counter() - start_time
-
+
# Verify both protocols work
assert len(http1_responses) == 10
assert len(http2_responses) == 10
-
+
for response in http1_responses:
assert response.status_code == 200
assert response.http_version == "HTTP/1.1"
-
+
for response in http2_responses:
assert response.status_code == 200
# HTTP version may be 1.1 or 2.0 depending on server support
-
+
# Record performance metrics
print(f"HTTP/1.1 time: {http1_time:.4f}s")
print(f"HTTP/2 time: {http2_time:.4f}s")
@@ -124,65 +132,69 @@ def test_protocol_comparison(self, local_test_server):
class TestConcurrentProtocolRequests:
"""Test concurrent request performance with different protocols."""
-
+
@pytest.mark.performance_local
async def test_concurrent_http1_requests(self, local_test_server):
"""Test concurrent HTTP/1.1 requests."""
-
+
async def make_request(client):
"""Make a single async request."""
- response = await client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = await client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
return response
-
+
async with httpx.AsyncClient(
base_url=local_test_server["base_url"],
http2=False,
limits=httpx.Limits(max_connections=10),
- timeout=30.0
+ timeout=30.0,
) as client:
# Make 10 concurrent requests
start_time = time.perf_counter()
tasks = [make_request(client) for _ in range(10)]
responses = await asyncio.gather(*tasks)
end_time = time.perf_counter()
-
+
# Verify all requests succeeded
assert len(responses) == 10
for response in responses:
assert response.status_code == 200
assert response.http_version == "HTTP/1.1"
-
+
concurrent_time = end_time - start_time
print(f"Concurrent HTTP/1.1 time: {concurrent_time:.4f}s")
print(f"Concurrent requests per second: {10/concurrent_time:.2f}")
-
+
@pytest.mark.performance_local
async def test_concurrent_http2_requests(self, local_test_server):
"""Test concurrent HTTP/2 requests."""
-
+
async def make_request(client):
"""Make a single async request."""
- response = await client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = await client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
return response
-
+
async with httpx.AsyncClient(
base_url=local_test_server["base_url"],
http2=True,
limits=httpx.Limits(max_connections=10),
- timeout=30.0
+ timeout=30.0,
) as client:
# Make 10 concurrent requests
start_time = time.perf_counter()
tasks = [make_request(client) for _ in range(10)]
responses = await asyncio.gather(*tasks)
end_time = time.perf_counter()
-
+
# Verify all requests succeeded
assert len(responses) == 10
for response in responses:
assert response.status_code == 200
# HTTP version depends on server support
-
+
concurrent_time = end_time - start_time
print(f"Concurrent HTTP/2 time: {concurrent_time:.4f}s")
print(f"Concurrent requests per second: {10/concurrent_time:.2f}")
@@ -190,34 +202,37 @@ async def make_request(client):
class TestProtocolConfiguration:
"""Test protocol configuration in HilltopClient."""
-
+
@pytest.mark.performance_local
def test_hilltop_client_default_protocol(self, local_test_server):
"""Test HilltopClient's default protocol configuration."""
-
+
with HilltopClient(
base_url=local_test_server["base_url"],
hts_endpoint=local_test_server["hts_endpoint"],
- timeout=30
+ timeout=30,
) as client:
# Make a request to see what protocol is used
response = client.get_status()
- assert hasattr(response, 'request')
-
+ assert hasattr(response, "request")
+
# Check the underlying session configuration
# HilltopClient uses httpx.Client which defaults to HTTP/1.1 unless http2=True
- assert hasattr(client.session, 'limits')
-
+ assert hasattr(client.session, "limits")
+
@pytest.mark.performance_local
def test_protocol_fallback_behavior(self, local_test_server):
"""Test protocol fallback behavior."""
-
+
# Test that HTTP/2 client can fallback to HTTP/1.1 if needed
with httpx.Client(
base_url=local_test_server["base_url"],
http2=True, # Enable HTTP/2 but allow fallback
- timeout=30.0
+ timeout=30.0,
) as client:
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
assert response.status_code == 200
- # Should work regardless of what protocol the server actually supports
\ No newline at end of file
+ # Should work regardless of what protocol the server actually supports
+
diff --git a/tests/performance/test_timeout_retry.py b/tests/performance/test_timeout_retry.py
index e0b2155..80ed611 100644
--- a/tests/performance/test_timeout_retry.py
+++ b/tests/performance/test_timeout_retry.py
@@ -18,11 +18,11 @@
class TestTimeoutConfiguration:
"""Test different timeout configuration effects."""
-
+
@pytest.mark.performance_local
def test_short_timeout_performance(self, benchmark, local_test_server):
"""Test performance with short timeout values."""
-
+
def make_requests_short_timeout():
"""Make requests with short timeout."""
with httpx.Client(
@@ -32,29 +32,31 @@ def make_requests_short_timeout():
results = []
for _ in range(3):
try:
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
results.append(response)
except httpx.TimeoutException:
# Count timeouts as results too
results.append(None)
return results
-
+
results = benchmark(make_requests_short_timeout)
-
+
# Should have attempted all requests
assert len(results) == 3
-
+
# Count successful vs timeout responses
successful = [r for r in results if r is not None and r.status_code == 200]
timeouts = [r for r in results if r is None]
-
+
print(f"Successful requests: {len(successful)}")
print(f"Timeout requests: {len(timeouts)}")
-
+
@pytest.mark.performance_local
def test_long_timeout_performance(self, benchmark, local_test_server):
"""Test performance with long timeout values."""
-
+
def make_requests_long_timeout():
"""Make requests with long timeout."""
with httpx.Client(
@@ -63,63 +65,67 @@ def make_requests_long_timeout():
) as client:
results = []
for _ in range(3):
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
results.append(response)
return results
-
+
results = benchmark(make_requests_long_timeout)
-
+
# All should succeed with long timeout
assert len(results) == 3
for result in results:
assert result.status_code == 200
-
+
@pytest.mark.performance_local
def test_timeout_granularity(self, local_test_server):
"""Test different timeout granularity settings."""
-
+
# Test fine-grained timeout configuration
timeout_config = httpx.Timeout(
- connect=5.0, # Connection timeout
- read=10.0, # Read timeout
- write=5.0, # Write timeout
- pool=15.0 # Pool timeout
+ connect=5.0, # Connection timeout
+ read=10.0, # Read timeout
+ write=5.0, # Write timeout
+ pool=15.0, # Pool timeout
)
-
+
start_time = time.perf_counter()
with httpx.Client(
- base_url=local_test_server["base_url"],
- timeout=timeout_config
+ base_url=local_test_server["base_url"], timeout=timeout_config
) as client:
results = []
for _ in range(5):
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
results.append(response)
end_time = time.perf_counter()
-
+
# Verify all succeeded
assert len(results) == 5
for result in results:
assert result.status_code == 200
-
+
granular_time = end_time - start_time
print(f"Granular timeout time: {granular_time:.4f}s")
-
+
# Compare with simple timeout
start_time = time.perf_counter()
with httpx.Client(
- base_url=local_test_server["base_url"],
- timeout=30.0 # Simple timeout
+ base_url=local_test_server["base_url"], timeout=30.0 # Simple timeout
) as client:
simple_results = []
for _ in range(5):
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
simple_results.append(response)
end_time = time.perf_counter()
-
+
simple_time = end_time - start_time
print(f"Simple timeout time: {simple_time:.4f}s")
-
+
# Both should work similarly for successful requests
assert len(simple_results) == 5
for result in simple_results:
@@ -128,45 +134,47 @@ def test_timeout_granularity(self, local_test_server):
class TestRetryBehavior:
"""Test retry behavior and its performance impact."""
-
+
@pytest.mark.performance_local
def test_no_retry_performance(self, benchmark, local_test_server):
"""Test performance without retry logic."""
-
+
def make_requests_no_retry():
"""Make requests without any retry logic."""
with httpx.Client(
- base_url=local_test_server["base_url"],
- timeout=30.0
+ base_url=local_test_server["base_url"], timeout=30.0
) as client:
results = []
for _ in range(5):
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
results.append(response)
return results
-
+
results = benchmark(make_requests_no_retry)
-
+
assert len(results) == 5
for result in results:
assert result.status_code == 200
-
+
@pytest.mark.performance_local
def test_manual_retry_performance(self, benchmark, local_test_server):
"""Test performance with manual retry logic."""
-
+
def make_requests_with_retry():
"""Make requests with manual retry logic."""
with httpx.Client(
- base_url=local_test_server["base_url"],
- timeout=5.0
+ base_url=local_test_server["base_url"], timeout=5.0
) as client:
results = []
for _ in range(3): # Fewer requests since we're adding retry overhead
max_retries = 3
for attempt in range(max_retries):
try:
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
results.append(response)
break # Success, no need to retry
except (httpx.TimeoutException, httpx.RequestError) as e:
@@ -177,44 +185,46 @@ def make_requests_with_retry():
# Wait before retry
time.sleep(0.1 * (attempt + 1)) # Exponential backoff
return results
-
+
results = benchmark(make_requests_with_retry)
-
+
assert len(results) == 3
successful = [r for r in results if r is not None and r.status_code == 200]
failed = [r for r in results if r is None]
-
+
print(f"Successful with retry: {len(successful)}")
print(f"Failed after retries: {len(failed)}")
-
+
@pytest.mark.performance_local
def test_retry_vs_no_retry_comparison(self, local_test_server):
"""Compare performance with and without retry logic."""
-
+
# Test without retry
start_time = time.perf_counter()
with httpx.Client(
- base_url=local_test_server["base_url"],
- timeout=30.0
+ base_url=local_test_server["base_url"], timeout=30.0
) as client:
no_retry_results = []
for _ in range(5):
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
no_retry_results.append(response)
no_retry_time = time.perf_counter() - start_time
-
+
# Test with retry logic
start_time = time.perf_counter()
with httpx.Client(
- base_url=local_test_server["base_url"],
- timeout=30.0
+ base_url=local_test_server["base_url"], timeout=30.0
) as client:
retry_results = []
for _ in range(5):
max_retries = 2
for attempt in range(max_retries):
try:
- response = client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
retry_results.append(response)
break
except (httpx.TimeoutException, httpx.RequestError):
@@ -223,54 +233,60 @@ def test_retry_vs_no_retry_comparison(self, local_test_server):
else:
time.sleep(0.05) # Small delay between retries
retry_time = time.perf_counter() - start_time
-
+
# Both should succeed in this case (no network errors expected)
assert len(no_retry_results) == 5
assert len(retry_results) == 5
-
+
for result in no_retry_results + retry_results:
if result is not None:
assert result.status_code == 200
-
+
print(f"No retry time: {no_retry_time:.4f}s")
print(f"With retry time: {retry_time:.4f}s")
- print(f"Retry overhead: {((retry_time - no_retry_time) / no_retry_time * 100):.1f}%")
+ print(
+ f"Retry overhead: {((retry_time - no_retry_time) / no_retry_time * 100):.1f}%"
+ )
class TestErrorHandlingPerformance:
"""Test error handling and recovery performance."""
-
+
@pytest.mark.performance_local
def test_connection_error_handling(self, local_test_server):
"""Test performance when handling connection errors."""
-
+
# Test requests to a non-existent endpoint
- invalid_url = local_test_server["base_url"].replace("8001", "9999") # Wrong port
-
+ invalid_url = local_test_server["base_url"].replace(
+ "8001", "9999"
+ ) # Wrong port
+
start_time = time.perf_counter()
error_count = 0
with httpx.Client(timeout=1.0) as client:
for _ in range(3):
try:
- response = client.get(f"{invalid_url}/foo.hts?Service=Hilltop&Request=Status")
+ response = client.get(
+ f"{invalid_url}/foo.hts?Service=Hilltop&Request=Status"
+ )
except (httpx.ConnectError, httpx.TimeoutException):
error_count += 1
error_handling_time = time.perf_counter() - start_time
-
+
print(f"Error handling time: {error_handling_time:.4f}s")
print(f"Errors encountered: {error_count}")
-
+
# Should have encountered connection errors
assert error_count > 0
-
+
@pytest.mark.performance_local
def test_mixed_success_failure_performance(self, local_test_server):
"""Test performance with mixed successful and failed requests."""
-
+
# Simulate a scenario with some failing requests
start_time = time.perf_counter()
results = []
-
+
with httpx.Client(timeout=2.0) as client:
# Mix of valid and invalid requests
requests = [
@@ -280,128 +296,132 @@ def test_mixed_success_failure_performance(self, local_test_server):
f"{local_test_server['base_url']}/foo.hts?Invalid=Request", # Invalid params
f"{local_test_server['base_url']}/foo.hts?Service=Hilltop&Request=Status", # Valid
]
-
+
for url in requests:
try:
response = client.get(url)
- results.append(('success', response.status_code))
+ results.append(("success", response.status_code))
except httpx.RequestError as e:
- results.append(('error', str(type(e).__name__)))
-
+ results.append(("error", str(type(e).__name__)))
+
mixed_time = time.perf_counter() - start_time
-
+
# Count successes and failures
- successes = [r for r in results if r[0] == 'success' and r[1] == 200]
- failures = [r for r in results if r[0] == 'error' or (r[0] == 'success' and r[1] != 200)]
-
+ successes = [r for r in results if r[0] == "success" and r[1] == 200]
+ failures = [
+ r for r in results if r[0] == "error" or (r[0] == "success" and r[1] != 200)
+ ]
+
print(f"Mixed requests time: {mixed_time:.4f}s")
print(f"Successful requests: {len(successes)}")
print(f"Failed requests: {len(failures)}")
-
+
# Should have a mix of successes and failures
assert len(results) == 5
assert len(successes) > 0 # Some requests should succeed
- assert len(failures) > 0 # Some requests should fail
+ assert len(failures) > 0 # Some requests should fail
class TestHilltopClientTimeoutBehavior:
"""Test HilltopClient timeout behavior."""
-
+
@pytest.mark.performance_local
def test_hilltop_client_default_timeout(self, local_test_server):
"""Test HilltopClient with default timeout configuration."""
-
+
start_time = time.perf_counter()
with HilltopClient(
base_url=local_test_server["base_url"],
hts_endpoint=local_test_server["hts_endpoint"],
- timeout=60 # Default timeout
+ timeout=60, # Default timeout
) as client:
results = []
for _ in range(3):
result = client.get_status()
results.append(result)
default_timeout_time = time.perf_counter() - start_time
-
+
assert len(results) == 3
for result in results:
- assert hasattr(result, 'request')
-
+ assert hasattr(result, "request")
+
print(f"Default timeout (60s) time: {default_timeout_time:.4f}s")
-
+
@pytest.mark.performance_local
def test_hilltop_client_custom_timeout(self, local_test_server):
"""Test HilltopClient with custom timeout configuration."""
-
+
# Test with shorter timeout
start_time = time.perf_counter()
with HilltopClient(
base_url=local_test_server["base_url"],
hts_endpoint=local_test_server["hts_endpoint"],
- timeout=10 # Custom shorter timeout
+ timeout=10, # Custom shorter timeout
) as client:
results = []
for _ in range(3):
result = client.get_status()
results.append(result)
custom_timeout_time = time.perf_counter() - start_time
-
+
assert len(results) == 3
for result in results:
- assert hasattr(result, 'request')
-
+ assert hasattr(result, "request")
+
print(f"Custom timeout (10s) time: {custom_timeout_time:.4f}s")
-
+
# For successful requests, timeout value shouldn't significantly affect performance
# (the actual network operations are the same)
assert custom_timeout_time > 0
-
+
@pytest.mark.performance_local
def test_hilltop_client_timeout_configuration(self, local_test_server):
"""Test that HilltopClient correctly configures httpx timeout."""
-
+
timeout_value = 15
with HilltopClient(
base_url=local_test_server["base_url"],
hts_endpoint=local_test_server["hts_endpoint"],
- timeout=timeout_value
+ timeout=timeout_value,
) as client:
# Verify the timeout is correctly configured in the underlying session
assert client.timeout == timeout_value
assert client.session.timeout.timeout == timeout_value
-
+
# Make a request to ensure it works
result = client.get_status()
- assert hasattr(result, 'request')
+ assert hasattr(result, "request")
class TestAsyncTimeoutBehavior:
"""Test async client timeout behavior."""
-
+
@pytest.mark.performance_local
async def test_async_client_timeout_performance(self, local_test_server):
"""Test async client timeout performance."""
-
+
# Test with various timeout configurations
timeout_configs = [5.0, 10.0, 30.0]
-
+
for timeout in timeout_configs:
start_time = time.perf_counter()
async with httpx.AsyncClient(
- base_url=local_test_server["base_url"],
- timeout=timeout
+ base_url=local_test_server["base_url"], timeout=timeout
) as client:
tasks = [
- client.get(f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status")
+ client.get(
+ f"/{local_test_server['hts_endpoint']}?Service=Hilltop&Request=Status"
+ )
for _ in range(3)
]
results = await asyncio.gather(*tasks)
end_time = time.perf_counter()
-
+
# Verify all succeeded
assert len(results) == 3
for result in results:
assert result.status_code == 200
-
+
async_time = end_time - start_time
- print(f"Async timeout {timeout}s time: {async_time:.4f}s")
\ No newline at end of file
+ print(f"Async timeout {timeout}s time: {async_time:.4f}s")
+