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/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/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/poetry.lock b/poetry.lock
index e4759a0..0dced8e 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,135 @@ 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 = "coverage"
+version = "7.10.6"
+description = "Code coverage measurement for Python"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "coverage-7.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356"},
+ {file = "coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301"},
+ {file = "coverage-7.10.6-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460"},
+ {file = "coverage-7.10.6-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd"},
+ {file = "coverage-7.10.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb"},
+ {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6"},
+ {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945"},
+ {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e"},
+ {file = "coverage-7.10.6-cp310-cp310-win32.whl", hash = "sha256:86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1"},
+ {file = "coverage-7.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528"},
+ {file = "coverage-7.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f"},
+ {file = "coverage-7.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc"},
+ {file = "coverage-7.10.6-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a"},
+ {file = "coverage-7.10.6-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a"},
+ {file = "coverage-7.10.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62"},
+ {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153"},
+ {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5"},
+ {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619"},
+ {file = "coverage-7.10.6-cp311-cp311-win32.whl", hash = "sha256:e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba"},
+ {file = "coverage-7.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e"},
+ {file = "coverage-7.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c"},
+ {file = "coverage-7.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea"},
+ {file = "coverage-7.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634"},
+ {file = "coverage-7.10.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6"},
+ {file = "coverage-7.10.6-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9"},
+ {file = "coverage-7.10.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c"},
+ {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a"},
+ {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5"},
+ {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972"},
+ {file = "coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d"},
+ {file = "coverage-7.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629"},
+ {file = "coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80"},
+ {file = "coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6"},
+ {file = "coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80"},
+ {file = "coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003"},
+ {file = "coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27"},
+ {file = "coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4"},
+ {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d"},
+ {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc"},
+ {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc"},
+ {file = "coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e"},
+ {file = "coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32"},
+ {file = "coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2"},
+ {file = "coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b"},
+ {file = "coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393"},
+ {file = "coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27"},
+ {file = "coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df"},
+ {file = "coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb"},
+ {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282"},
+ {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4"},
+ {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21"},
+ {file = "coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0"},
+ {file = "coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5"},
+ {file = "coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b"},
+ {file = "coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e"},
+ {file = "coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb"},
+ {file = "coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034"},
+ {file = "coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1"},
+ {file = "coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a"},
+ {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb"},
+ {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d"},
+ {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747"},
+ {file = "coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5"},
+ {file = "coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713"},
+ {file = "coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32"},
+ {file = "coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65"},
+ {file = "coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6"},
+ {file = "coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0"},
+ {file = "coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e"},
+ {file = "coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5"},
+ {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7"},
+ {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5"},
+ {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0"},
+ {file = "coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7"},
+ {file = "coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930"},
+ {file = "coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b"},
+ {file = "coverage-7.10.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90558c35af64971d65fbd935c32010f9a2f52776103a259f1dee865fe8259352"},
+ {file = "coverage-7.10.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8953746d371e5695405806c46d705a3cd170b9cc2b9f93953ad838f6c1e58612"},
+ {file = "coverage-7.10.6-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c83f6afb480eae0313114297d29d7c295670a41c11b274e6bca0c64540c1ce7b"},
+ {file = "coverage-7.10.6-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7eb68d356ba0cc158ca535ce1381dbf2037fa8cb5b1ae5ddfc302e7317d04144"},
+ {file = "coverage-7.10.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b15a87265e96307482746d86995f4bff282f14b027db75469c446da6127433b"},
+ {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fc53ba868875bfbb66ee447d64d6413c2db91fddcfca57025a0e7ab5b07d5862"},
+ {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:efeda443000aa23f276f4df973cb82beca682fd800bb119d19e80504ffe53ec2"},
+ {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9702b59d582ff1e184945d8b501ffdd08d2cee38d93a2206aa5f1365ce0b8d78"},
+ {file = "coverage-7.10.6-cp39-cp39-win32.whl", hash = "sha256:2195f8e16ba1a44651ca684db2ea2b2d4b5345da12f07d9c22a395202a05b23c"},
+ {file = "coverage-7.10.6-cp39-cp39-win_amd64.whl", hash = "sha256:f32ff80e7ef6a5b5b606ea69a36e97b219cd9dc799bcf2963018a4d8f788cfbf"},
+ {file = "coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3"},
+ {file = "coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90"},
+]
+
+[package.extras]
+toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
+
+[[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 +207,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 +282,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 +663,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 +846,67 @@ 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-cov"
+version = "7.0.0"
+description = "Pytest plugin for measuring coverage."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"},
+ {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"},
+]
+
+[package.dependencies]
+coverage = {version = ">=7.10.6", extras = ["toml"]}
+pluggy = ">=1.2"
+pytest = ">=7"
+
+[package.extras]
+testing = ["process-tests", "pytest-xdist", "virtualenv"]
+
[[package]]
name = "pytest-httpx"
version = "0.35.0"
@@ -822,6 +1073,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 +1131,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 +1165,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = ">=3.11"
-content-hash = "ba73f48dd659f82db52bed7f6ef4ec824cd0dfba142544891edc3a3f09511325"
+content-hash = "1b6383f9e3fe8590fa927fd6460968d04d286448d721eb02d5ec56a26add07d8"
diff --git a/pyproject.toml b/pyproject.toml
index 9acf97f..1ae41cd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,7 +20,14 @@ 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)",
+ "pytest-cov (>=7.0.0,<8.0.0)"
]
@@ -29,10 +36,13 @@ 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\"')",
+ "update: updates the api response data cache (deselect as '-m \"not update\"')",
"unit: marks tests as unit tests (deselect as '-m \"not unit\"')",
"integration: marks tests as integration tests (deselect as '-m \"not integration\"')",
"performance: marks tests as performance tests (deselect as '-m \"not performance\"')",
]
+addopts = "--cov=hurl --cov-report=term-missing"
diff --git a/tests/conftest.py b/tests/conftest.py
index cf84df2..af1fecd 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,5 +1,9 @@
"""Shared fixtures for testing."""
+import asyncio
+import os
+import threading
+import time
from pathlib import Path
import pytest
@@ -78,13 +82,16 @@ def pytest_collection_modifyitems(config, items):
if "unit" in item.keywords or "performance" in item.keywords:
item.add_marker(
pytest.mark.skip(
- reason="Only running integration tests in unit mode"
+ reason="Only running integration tests in integration mode"
)
)
if config.getoption("--mode") == "performance":
- raise pytest.PytestConfigWarning(
- "Performance tests are not yet implemented."
- )
+ if "integration" in item.keywords or "unit" in item.keywords:
+ item.add_marker(
+ pytest.mark.skip(
+ reason="Only running performance tests in performance mode"
+ )
+ )
if config.getoption("--update"):
if config.getoption("--mode") not in ["all", "integration"]:
@@ -155,3 +162,102 @@ def _factory(response_xml="payload", status_code=200):
}
return _factory
+
+
+@pytest.fixture(scope="session")
+def local_test_server():
+ """Start a local FastAPI test server for performance testing."""
+ import threading
+ import time
+
+ import httpx
+ import uvicorn
+
+ from tests.performance.local_server import create_test_server
+
+ # 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..47dca00
--- /dev/null
+++ b/tests/performance/__init__.py
@@ -0,0 +1 @@
+"""Performance testing module for HURL httpx features."""
diff --git a/tests/performance/local_server.py b/tests/performance/local_server.py
new file mode 100644
index 0000000..a596a31
--- /dev/null
+++ b/tests/performance/local_server.py
@@ -0,0 +1,203 @@
+"""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
+
+import uvicorn
+from fastapi import FastAPI, HTTPException, Response
+from fastapi.responses import PlainTextResponse
+
+
+class LocalTestServer:
+ """FastAPI-based local test server for performance testing."""
+
+ def __init__(
+ self,
+ host: str = "127.0.0.1",
+ port: int = 8000,
+ mocked_data_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)
+ mocked_data_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 mocked_data_path is None:
+ self.mocked_data_path = Path(__file__).parent.parent / "mocked_data"
+ else:
+ self.mocked_data_path = mocked_data_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):
+ """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
+
+ # 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.mocked_data_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:
+ """Create a test server with specified configuration (Factory function)."""
+ 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()
diff --git a/tests/performance/test_concurrency_features.py b/tests/performance/test_concurrency_features.py
new file mode 100644
index 0000000..a59d9a5
--- /dev/null
+++ b/tests/performance/test_concurrency_features.py
@@ -0,0 +1,376 @@
+"""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
+ 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
+ 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
+
+ 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)
+
+ # Verify all requests succeeded
+ assert len(results) == 5
+ for result in results:
+ assert result.status_code == 200
+
+ @pytest.mark.performance
+ 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
+ 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
+ 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
+
+ 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)
+
+ # Verify all requests succeeded
+ assert len(results) == 10
+ for result in results:
+ assert result.status_code == 200
+
+ @pytest.mark.performance
+ 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
+ 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
+ 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
+ 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}%"
+ )
+
diff --git a/tests/performance/test_connection_features.py b/tests/performance/test_connection_features.py
new file mode 100644
index 0000000..3807e32
--- /dev/null
+++ b/tests/performance/test_connection_features.py
@@ -0,0 +1,260 @@
+"""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
+ 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
+ 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
+ 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
+ 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,
+ ) 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
+
+ # 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
+ 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
+ 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
+ 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.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
+ 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 Council" in response.text # Basic XML validation
diff --git a/tests/performance/test_protocol_features.py b/tests/performance/test_protocol_features.py
new file mode 100644
index 0000000..21dd403
--- /dev/null
+++ b/tests/performance/test_protocol_features.py
@@ -0,0 +1,241 @@
+"""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
+ 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
+ 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
+ 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
+ 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
+ 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
+ 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
+
+ # NIC: Not sure what copilot is trying to assert here.
+ # This is not an attribute.
+
+ # assert hasattr(client.session, "limits")
+
+ @pytest.mark.performance
+ 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
diff --git a/tests/performance/test_timeout_retry.py b/tests/performance/test_timeout_retry.py
new file mode 100644
index 0000000..24e55ac
--- /dev/null
+++ b/tests/performance/test_timeout_retry.py
@@ -0,0 +1,429 @@
+"""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
+ 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
+ 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
+ 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
+ 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
+ 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
+ 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
+ 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
+ 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
+ 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
+ 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
+ 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.connect == timeout_value
+ assert client.session.timeout.read == timeout_value
+ assert client.session.timeout.write == timeout_value
+ assert client.session.timeout.pool == 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
+ 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")
diff --git a/tests/test_schemas/test_responses/test_collection_list_response.py b/tests/test_schemas/test_responses/test_collection_list_response.py
index 48e1099..ff48135 100644
--- a/tests/test_schemas/test_responses/test_collection_list_response.py
+++ b/tests/test_schemas/test_responses/test_collection_list_response.py
@@ -73,7 +73,7 @@ def fixture_func():
class TestRemoteFixtures:
@pytest.mark.remote
- @pytest.mark.update
+ @pytest.mark.integration
def test_basic_response_xml_fixture(
self,
remote_client,
diff --git a/tests/test_schemas/test_responses/test_get_data_response.py b/tests/test_schemas/test_responses/test_get_data_response.py
index 6387c96..b586510 100644
--- a/tests/test_schemas/test_responses/test_get_data_response.py
+++ b/tests/test_schemas/test_responses/test_get_data_response.py
@@ -127,7 +127,7 @@ def fixture_func():
class TestRemoteFixtures:
@pytest.mark.remote
- @pytest.mark.update
+ @pytest.mark.integration
def test_basic_response_xml_fixture(
self, remote_client, httpx_mock, basic_response_xml_cached
):
@@ -164,7 +164,7 @@ def test_basic_response_xml_fixture(
assert basic_response_xml_cleaned == remote_xml_cleaned
@pytest.mark.remote
- @pytest.mark.update
+ @pytest.mark.integration
def test_one_point_response_xml_fixture(
self, remote_client, httpx_mock, one_point_response_xml_cached
):
@@ -201,7 +201,7 @@ def test_one_point_response_xml_fixture(
assert one_point_response_xml_cleaned == remote_xml_cleaned
@pytest.mark.remote
- @pytest.mark.update
+ @pytest.mark.integration
def test_collection_response_xml_fixture(
self, remote_client, httpx_mock, collection_response_xml_cached
):
@@ -245,7 +245,7 @@ def test_collection_response_xml_fixture(
assert collection_response_xml_cleaned == remote_xml_cleaned
@pytest.mark.remote
- @pytest.mark.update
+ @pytest.mark.integration
def test_time_interval_response_xml_fixture(
self, remote_client, httpx_mock, time_interval_response_xml_cached
):
@@ -283,7 +283,7 @@ def test_time_interval_response_xml_fixture(
assert time_interval_response_xml_cleaned == remote_xml_cleaned
@pytest.mark.remote
- @pytest.mark.update
+ @pytest.mark.integration
def test_time_interval_complex_response_xml_fixture(
self, remote_client, httpx_mock, time_interval_complex_response_xml_cached
):
@@ -322,7 +322,7 @@ def test_time_interval_complex_response_xml_fixture(
assert time_interval_complex_response_xml_cleaned == remote_xml_cleaned
@pytest.mark.remote
- @pytest.mark.update
+ @pytest.mark.integration
def test_date_only_response_xml_fixture(
self, remote_client, httpx_mock, date_only_response_xml_cached
):
diff --git a/tests/test_schemas/test_responses/test_measurement_list_response.py b/tests/test_schemas/test_responses/test_measurement_list_response.py
index 186f653..7803c33 100644
--- a/tests/test_schemas/test_responses/test_measurement_list_response.py
+++ b/tests/test_schemas/test_responses/test_measurement_list_response.py
@@ -85,6 +85,7 @@ def fixture_func():
class VerifyCachedFixtures:
@pytest.mark.remote
+ @pytest.mark.integration
def test_multi_response_xml_cache(
self,
remote_client,
@@ -125,6 +126,7 @@ def test_multi_response_xml_cache(
assert remote_xml_cleaned == multi_response_xml_cleaned
@pytest.mark.remote
+ @pytest.mark.integration
def test_all_response_xml_fixture(
self,
remote_client,
@@ -153,6 +155,7 @@ def test_all_response_xml_fixture(
assert remote_xml == cached_xml
@pytest.mark.remote
+ @pytest.mark.integration
def test_error_response_xml_fixture(
self,
remote_client,
@@ -182,6 +185,7 @@ def test_error_response_xml_fixture(
assert remote_xml == cached_xml
@pytest.mark.remote
+ @pytest.mark.integration
def test_units_response_xml_fixture(
self,
remote_client,
diff --git a/tests/test_schemas/test_responses/test_site_info_response.py b/tests/test_schemas/test_responses/test_site_info_response.py
index 04e56ba..dce4260 100644
--- a/tests/test_schemas/test_responses/test_site_info_response.py
+++ b/tests/test_schemas/test_responses/test_site_info_response.py
@@ -77,7 +77,7 @@ def fixture_func():
class TestRemoteFixtures:
@pytest.mark.remote
- @pytest.mark.update
+ @pytest.mark.integration
def test_basic_response_xml_fixture(
self,
remote_client,
@@ -115,7 +115,7 @@ def test_basic_response_xml_fixture(
assert remote_xml_cleaned == basic_response_xml_cleaned
@pytest.mark.remote
- @pytest.mark.update
+ @pytest.mark.integration
def test_collection_response_xml_fixture(
self,
remote_client,
diff --git a/tests/test_schemas/test_responses/test_site_list_response.py b/tests/test_schemas/test_responses/test_site_list_response.py
index 2c0134a..8982652 100644
--- a/tests/test_schemas/test_responses/test_site_list_response.py
+++ b/tests/test_schemas/test_responses/test_site_list_response.py
@@ -70,7 +70,7 @@ def fixture_func():
class TestRemoteFixtures:
@pytest.mark.remote
- @pytest.mark.update
+ @pytest.mark.integration
def test_all_response_xml_fixture(
self, remote_client, httpx_mock, all_response_xml_cached
):
diff --git a/tests/test_schemas/test_responses/test_status_response.py b/tests/test_schemas/test_responses/test_status_response.py
index 319f0de..4276977 100644
--- a/tests/test_schemas/test_responses/test_status_response.py
+++ b/tests/test_schemas/test_responses/test_status_response.py
@@ -4,7 +4,6 @@
import pytest
import pytest_httpx
-
from dotenv import load_dotenv
load_dotenv()
@@ -22,10 +21,7 @@ def fixture_func(request, httpx_mock, remote_client):
from hurl.schemas.requests import StatusRequest
path = (
- Path(__file__).parent.parent.parent
- / "fixture_cache"
- / "status"
- / filename
+ Path(__file__).parent.parent.parent / "fixture_cache" / "status" / filename
)
if request.config.getoption("--update"):
# Switch off httpx mock so that cached request can go through.
@@ -42,6 +38,7 @@ def fixture_func(request, httpx_mock, remote_client):
path.write_text(cached_xml, encoding="utf-8")
raw_xml = path.read_text(encoding="utf-8")
return raw_xml
+
return fixture_func
@@ -53,14 +50,10 @@ def fixture_func():
"""Load test XML once per test session."""
from pathlib import Path
- path = (
- Path(__file__).parent.parent.parent
- / "mocked_data"
- / "status"
- / filename
- )
+ path = Path(__file__).parent.parent.parent / "mocked_data" / "status" / filename
raw_xml = path.read_text(encoding="utf-8")
return raw_xml
+
return fixture_func
@@ -73,8 +66,10 @@ def fixture_func():
class TestRemoteFixtures:
@pytest.mark.remote
- @pytest.mark.update
- def test_response_xml_fixture(self, httpx_mock, remote_client, status_response_xml_cached):
+ @pytest.mark.integration
+ def test_response_xml_fixture(
+ self, httpx_mock, remote_client, status_response_xml_cached
+ ):
"""Validate the status response XML fixture."""
from urllib.parse import urlparse
diff --git a/tests/test_schemas/test_responses/test_time_range_response.py b/tests/test_schemas/test_responses/test_time_range_response.py
index b93040d..9fab877 100644
--- a/tests/test_schemas/test_responses/test_time_range_response.py
+++ b/tests/test_schemas/test_responses/test_time_range_response.py
@@ -76,7 +76,7 @@ def fixture_func():
class TestRemoteFixtures:
@pytest.mark.remote
- @pytest.mark.update
+ @pytest.mark.integration
def test_basic_response_xml_fixture(
self,
remote_client,
diff --git a/tests/test_utils.py b/tests/test_utils.py
index a6073a1..bfca556 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,10 +1,11 @@
import pytest
+@pytest.mark.unit
def test_validate_hilltop_interval_notation():
"""Test validate_hilltop_interval_notation function."""
- from hurl.utils import validate_hilltop_interval_notation
from hurl.exceptions import HilltopRequestError
+ from hurl.utils import validate_hilltop_interval_notation
# Valid cases
assert validate_hilltop_interval_notation("10 seconds") == "10 seconds"