Spaces:
Build error
feat(proxy): add connection pooling, HTTP/2, and multi-worker support
Browse filesImprove proxy scalability for high-concurrency scenarios with multiple agents:
- Add connection pool configuration (max_connections=500, max_keepalive=100)
- Enable HTTP/2 multiplexing by default for better throughput
- Add multi-worker support via --workers flag for multi-core scaling
- Add --limit-concurrency flag for backpressure control
- Reuse httpx client for CCR continuations instead of creating new clients
- Add httpx[http2] dependency for HTTP/2 support
- Add comprehensive test suite for scalability features
New CLI flags:
--max-connections Max connections to upstream APIs (default: 500)
--max-keepalive Max keepalive connections (default: 100)
--no-http2 Disable HTTP/2 (enabled by default)
--workers Number of worker processes (default: 1)
--limit-concurrency Max concurrent connections before 503 (default: 1000)
- headroom/proxy/server.py +143 -43
- pyproject.toml +1 -1
- tests/test_proxy_scalability.py +245 -0
|
@@ -69,7 +69,13 @@ from headroom.ccr import (
|
|
| 69 |
get_batch_context_store,
|
| 70 |
parse_tool_call,
|
| 71 |
)
|
| 72 |
-
from headroom.config import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
from headroom.providers import AnthropicProvider, OpenAIProvider
|
| 74 |
from headroom.proxy.memory_handler import MemoryConfig, MemoryHandler
|
| 75 |
from headroom.telemetry import get_telemetry_collector
|
|
@@ -82,6 +88,7 @@ from headroom.transforms import (
|
|
| 82 |
CodeCompressorConfig,
|
| 83 |
ContentRouter,
|
| 84 |
ContentRouterConfig,
|
|
|
|
| 85 |
RollingWindow,
|
| 86 |
SmartCrusher,
|
| 87 |
TransformPipeline,
|
|
@@ -229,6 +236,11 @@ class ProxyConfig:
|
|
| 229 |
# Smart content routing (routes each message to optimal compressor)
|
| 230 |
smart_routing: bool = True # Use ContentRouter for intelligent compression
|
| 231 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
# Caching
|
| 233 |
cache_enabled: bool = True
|
| 234 |
cache_ttl_seconds: int = 3600 # 1 hour
|
|
@@ -263,6 +275,11 @@ class ProxyConfig:
|
|
| 263 |
request_timeout_seconds: int = 300
|
| 264 |
connect_timeout_seconds: int = 10
|
| 265 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
# Memory System
|
| 267 |
memory_enabled: bool = False # Enable memory integration
|
| 268 |
memory_backend: Literal["local", "qdrant-neo4j"] = "local" # Backend type
|
|
@@ -849,6 +866,32 @@ class HeadroomProxy:
|
|
| 849 |
self.openai_provider = OpenAIProvider()
|
| 850 |
|
| 851 |
# Initialize transforms based on routing mode
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 852 |
if config.smart_routing:
|
| 853 |
# Smart routing: ContentRouter handles all content types intelligently
|
| 854 |
# It lazy-loads compressors (including LLMLingua) only when needed
|
|
@@ -859,13 +902,7 @@ class HeadroomProxy:
|
|
| 859 |
transforms = [
|
| 860 |
CacheAligner(CacheAlignerConfig(enabled=True)),
|
| 861 |
ContentRouter(router_config),
|
| 862 |
-
|
| 863 |
-
RollingWindowConfig(
|
| 864 |
-
enabled=True,
|
| 865 |
-
keep_system=True,
|
| 866 |
-
keep_last_turns=config.keep_last_turns,
|
| 867 |
-
)
|
| 868 |
-
),
|
| 869 |
]
|
| 870 |
self._llmlingua_status = "lazy" if config.llmlingua_enabled else "disabled"
|
| 871 |
self._code_aware_status = "lazy" if config.code_aware_enabled else "disabled"
|
|
@@ -884,13 +921,7 @@ class HeadroomProxy:
|
|
| 884 |
inject_retrieval_marker=config.ccr_inject_tool, # Add CCR markers
|
| 885 |
),
|
| 886 |
),
|
| 887 |
-
|
| 888 |
-
RollingWindowConfig(
|
| 889 |
-
enabled=True,
|
| 890 |
-
keep_system=True,
|
| 891 |
-
keep_last_turns=config.keep_last_turns,
|
| 892 |
-
)
|
| 893 |
-
),
|
| 894 |
]
|
| 895 |
# Add LLMLingua if enabled and available
|
| 896 |
self._llmlingua_status = self._setup_llmlingua(config, transforms)
|
|
@@ -1083,12 +1114,22 @@ class HeadroomProxy:
|
|
| 1083 |
read=self.config.request_timeout_seconds,
|
| 1084 |
write=self.config.request_timeout_seconds,
|
| 1085 |
pool=self.config.connect_timeout_seconds,
|
| 1086 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1087 |
)
|
| 1088 |
logger.info("Headroom Proxy started")
|
| 1089 |
logger.info(f"Optimization: {'ENABLED' if self.config.optimize else 'DISABLED'}")
|
| 1090 |
logger.info(f"Caching: {'ENABLED' if self.config.cache_enabled else 'DISABLED'}")
|
| 1091 |
logger.info(f"Rate Limiting: {'ENABLED' if self.config.rate_limit_enabled else 'DISABLED'}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1092 |
|
| 1093 |
# Smart routing status
|
| 1094 |
if self.config.smart_routing:
|
|
@@ -1604,30 +1645,29 @@ class HeadroomProxy:
|
|
| 1604 |
)
|
| 1605 |
}
|
| 1606 |
|
| 1607 |
-
#
|
| 1608 |
logger.info(f"CCR: Making continuation request with {len(msgs)} messages")
|
| 1609 |
-
|
| 1610 |
-
|
| 1611 |
-
|
| 1612 |
-
|
| 1613 |
-
|
| 1614 |
-
|
| 1615 |
-
|
| 1616 |
-
|
| 1617 |
-
|
| 1618 |
-
|
| 1619 |
-
|
| 1620 |
-
|
| 1621 |
-
|
| 1622 |
-
|
| 1623 |
-
|
| 1624 |
-
|
| 1625 |
-
|
| 1626 |
-
|
| 1627 |
-
|
| 1628 |
-
|
| 1629 |
-
|
| 1630 |
-
raise
|
| 1631 |
|
| 1632 |
# Handle CCR tool calls
|
| 1633 |
try:
|
|
@@ -5872,8 +5912,18 @@ def _get_code_aware_banner_status(config: ProxyConfig) -> str:
|
|
| 5872 |
return "DISABLED"
|
| 5873 |
|
| 5874 |
|
| 5875 |
-
def run_server(
|
| 5876 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5877 |
if not FASTAPI_AVAILABLE:
|
| 5878 |
print("ERROR: FastAPI required. Install: pip install fastapi uvicorn httpx")
|
| 5879 |
sys.exit(1)
|
|
@@ -5884,12 +5934,17 @@ def run_server(config: ProxyConfig | None = None):
|
|
| 5884 |
llmlingua_status = _get_llmlingua_banner_status(config)
|
| 5885 |
code_aware_status = _get_code_aware_banner_status(config)
|
| 5886 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5887 |
print(f"""
|
| 5888 |
╔═══════════════════��══════════════════════════════════════════════════╗
|
| 5889 |
║ HEADROOM PROXY SERVER ║
|
| 5890 |
╠══════════════════════════════════════════════════════════════════════╣
|
| 5891 |
║ Version: 1.0.0 ║
|
| 5892 |
║ Listening: http://{config.host}:{config.port:<5} ║
|
|
|
|
| 5893 |
╠══════════════════════════════════════════════════════════════════════╣
|
| 5894 |
║ FEATURES: ║
|
| 5895 |
║ Optimization: {"ENABLED " if config.optimize else "DISABLED"} ║
|
|
@@ -5899,6 +5954,8 @@ def run_server(config: ProxyConfig | None = None):
|
|
| 5899 |
║ Cost Tracking: {"ENABLED " if config.cost_tracking_enabled else "DISABLED"} (budget: {"$" + str(config.budget_limit_usd) + "/" + config.budget_period if config.budget_limit_usd else "unlimited"}) ║
|
| 5900 |
║ LLMLingua: {llmlingua_status:<52}║
|
| 5901 |
║ Code-Aware: {code_aware_status:<52}║
|
|
|
|
|
|
|
| 5902 |
╠══════════════════════════════════════════════════════════════════════╣
|
| 5903 |
║ USAGE: ║
|
| 5904 |
║ Claude Code: ANTHROPIC_BASE_URL=http://{config.host}:{config.port} claude ║
|
|
@@ -5923,7 +5980,14 @@ def run_server(config: ProxyConfig | None = None):
|
|
| 5923 |
╚══════════════════════════════════════════════════════════════════════╝
|
| 5924 |
""")
|
| 5925 |
|
| 5926 |
-
uvicorn.run(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5927 |
|
| 5928 |
|
| 5929 |
def _get_env_bool(name: str, default: bool) -> bool:
|
|
@@ -5971,6 +6035,34 @@ if __name__ == "__main__":
|
|
| 5971 |
"--openai-api-url", help=f"Custom OpenAI API URL (default: {HeadroomProxy.OPENAI_API_URL})"
|
| 5972 |
)
|
| 5973 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5974 |
# Optimization
|
| 5975 |
parser.add_argument("--no-optimize", action="store_true", help="Disable optimization")
|
| 5976 |
parser.add_argument("--min-tokens", type=int, default=500, help="Min tokens to crush")
|
|
@@ -6087,6 +6179,14 @@ if __name__ == "__main__":
|
|
| 6087 |
llmlingua_device=_get_env_str("HEADROOM_LLMLINGUA_DEVICE", args.llmlingua_device),
|
| 6088 |
llmlingua_target_rate=_get_env_float("HEADROOM_LLMLINGUA_RATE", args.llmlingua_rate),
|
| 6089 |
code_aware_enabled=code_aware_enabled,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6090 |
)
|
| 6091 |
|
| 6092 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
get_batch_context_store,
|
| 70 |
parse_tool_call,
|
| 71 |
)
|
| 72 |
+
from headroom.config import (
|
| 73 |
+
CacheAlignerConfig,
|
| 74 |
+
CCRConfig,
|
| 75 |
+
IntelligentContextConfig,
|
| 76 |
+
RollingWindowConfig,
|
| 77 |
+
SmartCrusherConfig,
|
| 78 |
+
)
|
| 79 |
from headroom.providers import AnthropicProvider, OpenAIProvider
|
| 80 |
from headroom.proxy.memory_handler import MemoryConfig, MemoryHandler
|
| 81 |
from headroom.telemetry import get_telemetry_collector
|
|
|
|
| 88 |
CodeCompressorConfig,
|
| 89 |
ContentRouter,
|
| 90 |
ContentRouterConfig,
|
| 91 |
+
IntelligentContextManager,
|
| 92 |
RollingWindow,
|
| 93 |
SmartCrusher,
|
| 94 |
TransformPipeline,
|
|
|
|
| 236 |
# Smart content routing (routes each message to optimal compressor)
|
| 237 |
smart_routing: bool = True # Use ContentRouter for intelligent compression
|
| 238 |
|
| 239 |
+
# Intelligent context management (score-based dropping instead of age-based)
|
| 240 |
+
intelligent_context: bool = True # Use IntelligentContextManager instead of RollingWindow
|
| 241 |
+
intelligent_context_scoring: bool = True # Use multi-factor importance scoring
|
| 242 |
+
intelligent_context_compress_first: bool = True # Try deeper compression before dropping
|
| 243 |
+
|
| 244 |
# Caching
|
| 245 |
cache_enabled: bool = True
|
| 246 |
cache_ttl_seconds: int = 3600 # 1 hour
|
|
|
|
| 275 |
request_timeout_seconds: int = 300
|
| 276 |
connect_timeout_seconds: int = 10
|
| 277 |
|
| 278 |
+
# Connection pool (for high concurrency with multiple agents)
|
| 279 |
+
max_connections: int = 500 # Max total connections to upstream APIs
|
| 280 |
+
max_keepalive_connections: int = 100 # Max idle connections to keep alive
|
| 281 |
+
http2: bool = True # Enable HTTP/2 multiplexing for better throughput
|
| 282 |
+
|
| 283 |
# Memory System
|
| 284 |
memory_enabled: bool = False # Enable memory integration
|
| 285 |
memory_backend: Literal["local", "qdrant-neo4j"] = "local" # Backend type
|
|
|
|
| 866 |
self.openai_provider = OpenAIProvider()
|
| 867 |
|
| 868 |
# Initialize transforms based on routing mode
|
| 869 |
+
# Choose context manager: IntelligentContextManager (smart) or RollingWindow (legacy)
|
| 870 |
+
if config.intelligent_context:
|
| 871 |
+
# Get TOIN instance for learned pattern integration
|
| 872 |
+
toin = get_toin() if config.intelligent_context_scoring else None
|
| 873 |
+
context_manager = IntelligentContextManager(
|
| 874 |
+
config=IntelligentContextConfig(
|
| 875 |
+
enabled=True,
|
| 876 |
+
keep_system=True,
|
| 877 |
+
keep_last_turns=config.keep_last_turns,
|
| 878 |
+
use_importance_scoring=config.intelligent_context_scoring,
|
| 879 |
+
toin_integration=config.intelligent_context_scoring,
|
| 880 |
+
compress_threshold=0.10 if config.intelligent_context_compress_first else 0.0,
|
| 881 |
+
),
|
| 882 |
+
toin=toin,
|
| 883 |
+
)
|
| 884 |
+
self._context_manager_status = "intelligent"
|
| 885 |
+
else:
|
| 886 |
+
context_manager = RollingWindow(
|
| 887 |
+
RollingWindowConfig(
|
| 888 |
+
enabled=True,
|
| 889 |
+
keep_system=True,
|
| 890 |
+
keep_last_turns=config.keep_last_turns,
|
| 891 |
+
)
|
| 892 |
+
)
|
| 893 |
+
self._context_manager_status = "rolling_window"
|
| 894 |
+
|
| 895 |
if config.smart_routing:
|
| 896 |
# Smart routing: ContentRouter handles all content types intelligently
|
| 897 |
# It lazy-loads compressors (including LLMLingua) only when needed
|
|
|
|
| 902 |
transforms = [
|
| 903 |
CacheAligner(CacheAlignerConfig(enabled=True)),
|
| 904 |
ContentRouter(router_config),
|
| 905 |
+
context_manager,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 906 |
]
|
| 907 |
self._llmlingua_status = "lazy" if config.llmlingua_enabled else "disabled"
|
| 908 |
self._code_aware_status = "lazy" if config.code_aware_enabled else "disabled"
|
|
|
|
| 921 |
inject_retrieval_marker=config.ccr_inject_tool, # Add CCR markers
|
| 922 |
),
|
| 923 |
),
|
| 924 |
+
context_manager,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 925 |
]
|
| 926 |
# Add LLMLingua if enabled and available
|
| 927 |
self._llmlingua_status = self._setup_llmlingua(config, transforms)
|
|
|
|
| 1114 |
read=self.config.request_timeout_seconds,
|
| 1115 |
write=self.config.request_timeout_seconds,
|
| 1116 |
pool=self.config.connect_timeout_seconds,
|
| 1117 |
+
),
|
| 1118 |
+
limits=httpx.Limits(
|
| 1119 |
+
max_connections=self.config.max_connections,
|
| 1120 |
+
max_keepalive_connections=self.config.max_keepalive_connections,
|
| 1121 |
+
),
|
| 1122 |
+
http2=self.config.http2,
|
| 1123 |
)
|
| 1124 |
logger.info("Headroom Proxy started")
|
| 1125 |
logger.info(f"Optimization: {'ENABLED' if self.config.optimize else 'DISABLED'}")
|
| 1126 |
logger.info(f"Caching: {'ENABLED' if self.config.cache_enabled else 'DISABLED'}")
|
| 1127 |
logger.info(f"Rate Limiting: {'ENABLED' if self.config.rate_limit_enabled else 'DISABLED'}")
|
| 1128 |
+
logger.info(
|
| 1129 |
+
f"Connection Pool: max_connections={self.config.max_connections}, "
|
| 1130 |
+
f"max_keepalive={self.config.max_keepalive_connections}, "
|
| 1131 |
+
f"http2={'ENABLED' if self.config.http2 else 'DISABLED'}"
|
| 1132 |
+
)
|
| 1133 |
|
| 1134 |
# Smart routing status
|
| 1135 |
if self.config.smart_routing:
|
|
|
|
| 1645 |
)
|
| 1646 |
}
|
| 1647 |
|
| 1648 |
+
# Reuse main client for CCR continuations (connection pooling)
|
| 1649 |
logger.info(f"CCR: Making continuation request with {len(msgs)} messages")
|
| 1650 |
+
assert self.http_client is not None, "HTTP client not initialized"
|
| 1651 |
+
try:
|
| 1652 |
+
cont_response = await self.http_client.post(
|
| 1653 |
+
url,
|
| 1654 |
+
json=continuation_body,
|
| 1655 |
+
headers=continuation_headers,
|
| 1656 |
+
timeout=httpx.Timeout(120.0), # Override timeout for CCR
|
| 1657 |
+
)
|
| 1658 |
+
logger.info(
|
| 1659 |
+
f"CCR: Got response status={cont_response.status_code}, "
|
| 1660 |
+
f"content-encoding={cont_response.headers.get('content-encoding')}"
|
| 1661 |
+
)
|
| 1662 |
+
result: dict[str, Any] = cont_response.json()
|
| 1663 |
+
logger.info("CCR: Parsed JSON successfully")
|
| 1664 |
+
return result
|
| 1665 |
+
except Exception as e:
|
| 1666 |
+
logger.error(
|
| 1667 |
+
f"CCR: API call failed: {e}, "
|
| 1668 |
+
f"response headers: {dict(cont_response.headers) if 'cont_response' in dir() else 'N/A'}"
|
| 1669 |
+
)
|
| 1670 |
+
raise
|
|
|
|
| 1671 |
|
| 1672 |
# Handle CCR tool calls
|
| 1673 |
try:
|
|
|
|
| 5912 |
return "DISABLED"
|
| 5913 |
|
| 5914 |
|
| 5915 |
+
def run_server(
|
| 5916 |
+
config: ProxyConfig | None = None,
|
| 5917 |
+
workers: int = 1,
|
| 5918 |
+
limit_concurrency: int = 1000,
|
| 5919 |
+
):
|
| 5920 |
+
"""Run the proxy server.
|
| 5921 |
+
|
| 5922 |
+
Args:
|
| 5923 |
+
config: Proxy configuration
|
| 5924 |
+
workers: Number of worker processes (use N for multi-core scaling)
|
| 5925 |
+
limit_concurrency: Max concurrent connections before 503 response
|
| 5926 |
+
"""
|
| 5927 |
if not FASTAPI_AVAILABLE:
|
| 5928 |
print("ERROR: FastAPI required. Install: pip install fastapi uvicorn httpx")
|
| 5929 |
sys.exit(1)
|
|
|
|
| 5934 |
llmlingua_status = _get_llmlingua_banner_status(config)
|
| 5935 |
code_aware_status = _get_code_aware_banner_status(config)
|
| 5936 |
|
| 5937 |
+
# Format connection pool info
|
| 5938 |
+
pool_info = f"max={config.max_connections}, keepalive={config.max_keepalive_connections}"
|
| 5939 |
+
http2_status = "ENABLED" if config.http2 else "DISABLED"
|
| 5940 |
+
|
| 5941 |
print(f"""
|
| 5942 |
╔═══════════════════��══════════════════════════════════════════════════╗
|
| 5943 |
║ HEADROOM PROXY SERVER ║
|
| 5944 |
╠══════════════════════════════════════════════════════════════════════╣
|
| 5945 |
║ Version: 1.0.0 ║
|
| 5946 |
║ Listening: http://{config.host}:{config.port:<5} ║
|
| 5947 |
+
║ Workers: {workers:<3} Concurrency Limit: {limit_concurrency:<5} ║
|
| 5948 |
╠══════════════════════════════════════════════════════════════════════╣
|
| 5949 |
║ FEATURES: ║
|
| 5950 |
║ Optimization: {"ENABLED " if config.optimize else "DISABLED"} ║
|
|
|
|
| 5954 |
║ Cost Tracking: {"ENABLED " if config.cost_tracking_enabled else "DISABLED"} (budget: {"$" + str(config.budget_limit_usd) + "/" + config.budget_period if config.budget_limit_usd else "unlimited"}) ║
|
| 5955 |
║ LLMLingua: {llmlingua_status:<52}║
|
| 5956 |
║ Code-Aware: {code_aware_status:<52}║
|
| 5957 |
+
║ HTTP/2: {http2_status:<52}║
|
| 5958 |
+
║ Conn Pool: {pool_info:<52}║
|
| 5959 |
╠══════════════════════════════════════════════════════════════════════╣
|
| 5960 |
║ USAGE: ║
|
| 5961 |
║ Claude Code: ANTHROPIC_BASE_URL=http://{config.host}:{config.port} claude ║
|
|
|
|
| 5980 |
╚══════════════════════════════════════════════════════════════════════╝
|
| 5981 |
""")
|
| 5982 |
|
| 5983 |
+
uvicorn.run(
|
| 5984 |
+
app,
|
| 5985 |
+
host=config.host,
|
| 5986 |
+
port=config.port,
|
| 5987 |
+
log_level="warning",
|
| 5988 |
+
workers=workers if workers > 1 else None, # None = single process (default)
|
| 5989 |
+
limit_concurrency=limit_concurrency,
|
| 5990 |
+
)
|
| 5991 |
|
| 5992 |
|
| 5993 |
def _get_env_bool(name: str, default: bool) -> bool:
|
|
|
|
| 6035 |
"--openai-api-url", help=f"Custom OpenAI API URL (default: {HeadroomProxy.OPENAI_API_URL})"
|
| 6036 |
)
|
| 6037 |
|
| 6038 |
+
# Connection pool (scalability)
|
| 6039 |
+
parser.add_argument(
|
| 6040 |
+
"--max-connections",
|
| 6041 |
+
type=int,
|
| 6042 |
+
default=500,
|
| 6043 |
+
help="Max connections to upstream APIs (default: 500)",
|
| 6044 |
+
)
|
| 6045 |
+
parser.add_argument(
|
| 6046 |
+
"--max-keepalive", type=int, default=100, help="Max keepalive connections (default: 100)"
|
| 6047 |
+
)
|
| 6048 |
+
parser.add_argument(
|
| 6049 |
+
"--no-http2",
|
| 6050 |
+
action="store_true",
|
| 6051 |
+
help="Disable HTTP/2 (enabled by default for better throughput)",
|
| 6052 |
+
)
|
| 6053 |
+
parser.add_argument(
|
| 6054 |
+
"--workers",
|
| 6055 |
+
type=int,
|
| 6056 |
+
default=1,
|
| 6057 |
+
help="Number of worker processes (default: 1, use N for multi-core)",
|
| 6058 |
+
)
|
| 6059 |
+
parser.add_argument(
|
| 6060 |
+
"--limit-concurrency",
|
| 6061 |
+
type=int,
|
| 6062 |
+
default=1000,
|
| 6063 |
+
help="Max concurrent connections before 503 (default: 1000)",
|
| 6064 |
+
)
|
| 6065 |
+
|
| 6066 |
# Optimization
|
| 6067 |
parser.add_argument("--no-optimize", action="store_true", help="Disable optimization")
|
| 6068 |
parser.add_argument("--min-tokens", type=int, default=500, help="Min tokens to crush")
|
|
|
|
| 6179 |
llmlingua_device=_get_env_str("HEADROOM_LLMLINGUA_DEVICE", args.llmlingua_device),
|
| 6180 |
llmlingua_target_rate=_get_env_float("HEADROOM_LLMLINGUA_RATE", args.llmlingua_rate),
|
| 6181 |
code_aware_enabled=code_aware_enabled,
|
| 6182 |
+
# Connection pool settings
|
| 6183 |
+
max_connections=_get_env_int("HEADROOM_MAX_CONNECTIONS", args.max_connections),
|
| 6184 |
+
max_keepalive_connections=_get_env_int("HEADROOM_MAX_KEEPALIVE", args.max_keepalive),
|
| 6185 |
+
http2=not args.no_http2 and _get_env_bool("HEADROOM_HTTP2", True),
|
| 6186 |
)
|
| 6187 |
|
| 6188 |
+
# Get worker and concurrency settings
|
| 6189 |
+
workers = _get_env_int("HEADROOM_WORKERS", args.workers)
|
| 6190 |
+
limit_concurrency = _get_env_int("HEADROOM_LIMIT_CONCURRENCY", args.limit_concurrency)
|
| 6191 |
+
|
| 6192 |
+
run_server(config, workers=workers, limit_concurrency=limit_concurrency)
|
|
@@ -68,7 +68,7 @@ relevance = [
|
|
| 68 |
proxy = [
|
| 69 |
"fastapi>=0.100.0",
|
| 70 |
"uvicorn>=0.23.0",
|
| 71 |
-
"httpx>=0.24.0",
|
| 72 |
]
|
| 73 |
# Report generation
|
| 74 |
reports = [
|
|
|
|
| 68 |
proxy = [
|
| 69 |
"fastapi>=0.100.0",
|
| 70 |
"uvicorn>=0.23.0",
|
| 71 |
+
"httpx[http2]>=0.24.0", # http2 extra enables h2 for HTTP/2 support
|
| 72 |
]
|
| 73 |
# Report generation
|
| 74 |
reports = [
|
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for proxy scalability features.
|
| 2 |
+
|
| 3 |
+
These tests verify connection pooling, HTTP/2, and worker configuration.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
|
| 8 |
+
import httpx
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class TestConnectionPoolConfig:
|
| 13 |
+
"""Test connection pool configuration."""
|
| 14 |
+
|
| 15 |
+
def test_httpx_limits_basic(self):
|
| 16 |
+
"""Test that httpx accepts our connection limits."""
|
| 17 |
+
limits = httpx.Limits(
|
| 18 |
+
max_connections=500,
|
| 19 |
+
max_keepalive_connections=100,
|
| 20 |
+
)
|
| 21 |
+
assert limits.max_connections == 500
|
| 22 |
+
assert limits.max_keepalive_connections == 100
|
| 23 |
+
|
| 24 |
+
def test_httpx_limits_custom(self):
|
| 25 |
+
"""Test custom connection limits."""
|
| 26 |
+
limits = httpx.Limits(
|
| 27 |
+
max_connections=1000,
|
| 28 |
+
max_keepalive_connections=200,
|
| 29 |
+
)
|
| 30 |
+
assert limits.max_connections == 1000
|
| 31 |
+
assert limits.max_keepalive_connections == 200
|
| 32 |
+
|
| 33 |
+
def test_httpx_timeout_config(self):
|
| 34 |
+
"""Test timeout configuration for proxy."""
|
| 35 |
+
timeout = httpx.Timeout(
|
| 36 |
+
connect=10.0,
|
| 37 |
+
read=300.0,
|
| 38 |
+
write=300.0,
|
| 39 |
+
pool=10.0,
|
| 40 |
+
)
|
| 41 |
+
assert timeout.connect == 10.0
|
| 42 |
+
assert timeout.read == 300.0
|
| 43 |
+
assert timeout.write == 300.0
|
| 44 |
+
assert timeout.pool == 10.0
|
| 45 |
+
|
| 46 |
+
@pytest.mark.asyncio
|
| 47 |
+
async def test_async_client_with_limits(self):
|
| 48 |
+
"""Test AsyncClient accepts connection pool limits."""
|
| 49 |
+
limits = httpx.Limits(
|
| 50 |
+
max_connections=500,
|
| 51 |
+
max_keepalive_connections=100,
|
| 52 |
+
)
|
| 53 |
+
async with httpx.AsyncClient(
|
| 54 |
+
limits=limits,
|
| 55 |
+
timeout=httpx.Timeout(10.0),
|
| 56 |
+
) as client:
|
| 57 |
+
# Verify client was created successfully with our limits
|
| 58 |
+
# (httpx doesn't expose limits directly, but creation succeeds)
|
| 59 |
+
assert client is not None
|
| 60 |
+
# The limits object we passed should have our values
|
| 61 |
+
assert limits.max_connections == 500
|
| 62 |
+
assert limits.max_keepalive_connections == 100
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class TestHTTP2Config:
|
| 66 |
+
"""Test HTTP/2 configuration."""
|
| 67 |
+
|
| 68 |
+
def test_http2_requires_h2_package(self):
|
| 69 |
+
"""Test that http2=True requires h2 package."""
|
| 70 |
+
import importlib.util
|
| 71 |
+
|
| 72 |
+
h2_available = importlib.util.find_spec("h2") is not None
|
| 73 |
+
|
| 74 |
+
if h2_available:
|
| 75 |
+
# Should work if h2 is installed
|
| 76 |
+
client = httpx.Client(http2=True)
|
| 77 |
+
assert client._base_url is not None
|
| 78 |
+
client.close()
|
| 79 |
+
else:
|
| 80 |
+
# Should raise if h2 not installed
|
| 81 |
+
with pytest.raises(ImportError):
|
| 82 |
+
httpx.Client(http2=True)
|
| 83 |
+
|
| 84 |
+
@pytest.mark.asyncio
|
| 85 |
+
async def test_async_client_http2(self):
|
| 86 |
+
"""Test AsyncClient with HTTP/2 enabled."""
|
| 87 |
+
import importlib.util
|
| 88 |
+
|
| 89 |
+
if not importlib.util.find_spec("h2"):
|
| 90 |
+
pytest.skip("h2 package not installed")
|
| 91 |
+
|
| 92 |
+
async with httpx.AsyncClient(
|
| 93 |
+
http2=True,
|
| 94 |
+
limits=httpx.Limits(max_connections=100),
|
| 95 |
+
) as client:
|
| 96 |
+
# Client should be configured for HTTP/2
|
| 97 |
+
assert client is not None
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class TestProxyConfigDataclass:
|
| 101 |
+
"""Test ProxyConfig dataclass with new fields."""
|
| 102 |
+
|
| 103 |
+
def test_proxy_config_defaults(self):
|
| 104 |
+
"""Test default values for scalability settings."""
|
| 105 |
+
from dataclasses import dataclass
|
| 106 |
+
|
| 107 |
+
@dataclass
|
| 108 |
+
class ProxyConfigTest:
|
| 109 |
+
"""Minimal proxy config for testing."""
|
| 110 |
+
|
| 111 |
+
host: str = "127.0.0.1"
|
| 112 |
+
port: int = 8787
|
| 113 |
+
request_timeout_seconds: int = 300
|
| 114 |
+
connect_timeout_seconds: int = 10
|
| 115 |
+
max_connections: int = 500
|
| 116 |
+
max_keepalive_connections: int = 100
|
| 117 |
+
http2: bool = True
|
| 118 |
+
|
| 119 |
+
config = ProxyConfigTest()
|
| 120 |
+
assert config.max_connections == 500
|
| 121 |
+
assert config.max_keepalive_connections == 100
|
| 122 |
+
assert config.http2 is True
|
| 123 |
+
|
| 124 |
+
def test_proxy_config_custom_values(self):
|
| 125 |
+
"""Test custom values for scalability settings."""
|
| 126 |
+
from dataclasses import dataclass
|
| 127 |
+
|
| 128 |
+
@dataclass
|
| 129 |
+
class ProxyConfigTest:
|
| 130 |
+
max_connections: int = 500
|
| 131 |
+
max_keepalive_connections: int = 100
|
| 132 |
+
http2: bool = True
|
| 133 |
+
|
| 134 |
+
config = ProxyConfigTest(
|
| 135 |
+
max_connections=1000,
|
| 136 |
+
max_keepalive_connections=200,
|
| 137 |
+
http2=False,
|
| 138 |
+
)
|
| 139 |
+
assert config.max_connections == 1000
|
| 140 |
+
assert config.max_keepalive_connections == 200
|
| 141 |
+
assert config.http2 is False
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
class TestConcurrencyPatterns:
|
| 145 |
+
"""Test async concurrency patterns used in proxy."""
|
| 146 |
+
|
| 147 |
+
@pytest.mark.asyncio
|
| 148 |
+
async def test_semaphore_for_backpressure(self):
|
| 149 |
+
"""Test semaphore pattern for limiting concurrent requests."""
|
| 150 |
+
semaphore = asyncio.Semaphore(3)
|
| 151 |
+
active = []
|
| 152 |
+
completed = []
|
| 153 |
+
|
| 154 |
+
async def task(task_id: int):
|
| 155 |
+
async with semaphore:
|
| 156 |
+
active.append(task_id)
|
| 157 |
+
# Verify we never exceed semaphore limit
|
| 158 |
+
assert len(active) <= 3
|
| 159 |
+
await asyncio.sleep(0.01)
|
| 160 |
+
active.remove(task_id)
|
| 161 |
+
completed.append(task_id)
|
| 162 |
+
|
| 163 |
+
# Run 10 tasks with max 3 concurrent
|
| 164 |
+
tasks = [task(i) for i in range(10)]
|
| 165 |
+
await asyncio.gather(*tasks)
|
| 166 |
+
|
| 167 |
+
assert len(completed) == 10
|
| 168 |
+
|
| 169 |
+
@pytest.mark.asyncio
|
| 170 |
+
async def test_connection_reuse_pattern(self):
|
| 171 |
+
"""Test that single client instance is reused (not recreated)."""
|
| 172 |
+
clients_created = []
|
| 173 |
+
|
| 174 |
+
class MockProxyWithClient:
|
| 175 |
+
def __init__(self):
|
| 176 |
+
self.http_client = None
|
| 177 |
+
|
| 178 |
+
async def startup(self):
|
| 179 |
+
self.http_client = httpx.AsyncClient(
|
| 180 |
+
limits=httpx.Limits(max_connections=100),
|
| 181 |
+
)
|
| 182 |
+
clients_created.append(self.http_client)
|
| 183 |
+
|
| 184 |
+
async def shutdown(self):
|
| 185 |
+
if self.http_client:
|
| 186 |
+
await self.http_client.aclose()
|
| 187 |
+
|
| 188 |
+
async def make_request(self, url: str):
|
| 189 |
+
# Should reuse the same client, not create new one
|
| 190 |
+
return self.http_client
|
| 191 |
+
|
| 192 |
+
proxy = MockProxyWithClient()
|
| 193 |
+
await proxy.startup()
|
| 194 |
+
|
| 195 |
+
# Multiple requests should return same client instance
|
| 196 |
+
client1 = await proxy.make_request("http://example1.com")
|
| 197 |
+
client2 = await proxy.make_request("http://example2.com")
|
| 198 |
+
client3 = await proxy.make_request("http://example3.com")
|
| 199 |
+
|
| 200 |
+
assert client1 is client2 is client3
|
| 201 |
+
assert len(clients_created) == 1 # Only one client created
|
| 202 |
+
|
| 203 |
+
await proxy.shutdown()
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
class TestTimeoutOverrides:
|
| 207 |
+
"""Test per-request timeout overrides."""
|
| 208 |
+
|
| 209 |
+
@pytest.mark.asyncio
|
| 210 |
+
async def test_request_level_timeout_override(self):
|
| 211 |
+
"""Test that timeout can be overridden per-request."""
|
| 212 |
+
async with httpx.AsyncClient(
|
| 213 |
+
timeout=httpx.Timeout(10.0), # Default timeout
|
| 214 |
+
):
|
| 215 |
+
# Per-request override should work
|
| 216 |
+
override_timeout = httpx.Timeout(120.0)
|
| 217 |
+
# Just verify the timeout object is valid
|
| 218 |
+
assert override_timeout.read == 120.0
|
| 219 |
+
assert override_timeout.connect == 120.0
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
class TestWorkerConfiguration:
|
| 223 |
+
"""Test worker process configuration."""
|
| 224 |
+
|
| 225 |
+
def test_uvicorn_workers_parameter(self):
|
| 226 |
+
"""Test that uvicorn accepts workers parameter."""
|
| 227 |
+
# uvicorn.run accepts workers=N for multi-process
|
| 228 |
+
import uvicorn
|
| 229 |
+
|
| 230 |
+
# Verify the Config class accepts workers
|
| 231 |
+
config = uvicorn.Config(
|
| 232 |
+
app="app:app",
|
| 233 |
+
workers=4,
|
| 234 |
+
limit_concurrency=1000,
|
| 235 |
+
)
|
| 236 |
+
assert config.workers == 4
|
| 237 |
+
assert config.limit_concurrency == 1000
|
| 238 |
+
|
| 239 |
+
def test_single_worker_default(self):
|
| 240 |
+
"""Test that default is single worker (None)."""
|
| 241 |
+
import uvicorn
|
| 242 |
+
|
| 243 |
+
config = uvicorn.Config(app="app:app")
|
| 244 |
+
# Default should be None (single process)
|
| 245 |
+
assert config.workers is None or config.workers == 1
|