Spaces:
Sleeping
Sleeping
File size: 6,448 Bytes
e98cc10 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | """
HTTP Client Pool for Medium-MCP
Singleton pattern for httpx.AsyncClient connection pooling.
Addresses Critical Gap #5: Connection Leaks.
Based on httpx official documentation best practices:
- Single AsyncClient instance for connection reuse
- Configurable limits via httpx.Limits
- Proper async cleanup with aclose()
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, AsyncGenerator
import httpx
from src.constants import (
DEFAULT_CONNECT_TIMEOUT,
DEFAULT_KEEPALIVE_CONNECTIONS,
DEFAULT_KEEPALIVE_EXPIRY,
DEFAULT_MAX_CONNECTIONS,
DEFAULT_READ_TIMEOUT,
DEFAULT_USER_AGENT,
)
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
class HTTPClientPool:
"""
Singleton HTTP client pool for connection reuse.
This class ensures a single httpx.AsyncClient instance is shared
across the entire application, providing:
- Connection pooling (reuse TCP connections)
- HTTP/2 support
- Automatic redirect following
- Proper resource cleanup
Usage:
pool = HTTPClientPool()
client = await pool.get_client()
response = await client.get("https://example.com")
# Or with context manager:
async with http_session() as client:
response = await client.get("https://example.com")
"""
_instance: "HTTPClientPool | None" = None
_client: httpx.AsyncClient | None = None
def __new__(cls) -> "HTTPClientPool":
"""Ensure only one instance exists (Singleton pattern)."""
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
async def get_client(
self,
*,
max_connections: int = DEFAULT_MAX_CONNECTIONS,
max_keepalive_connections: int = DEFAULT_KEEPALIVE_CONNECTIONS,
keepalive_expiry: float = DEFAULT_KEEPALIVE_EXPIRY,
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
read_timeout: float = DEFAULT_READ_TIMEOUT,
) -> httpx.AsyncClient:
"""
Get or create the shared HTTP client.
Args:
max_connections: Maximum concurrent connections
max_keepalive_connections: Maximum idle connections to keep
keepalive_expiry: How long to keep idle connections (seconds)
connect_timeout: Timeout for establishing connections
read_timeout: Timeout for reading responses
Returns:
Configured httpx.AsyncClient instance
"""
if self._client is None or self._client.is_closed:
logger.info("Creating new HTTP client pool")
limits = httpx.Limits(
max_connections=max_connections,
max_keepalive_connections=max_keepalive_connections,
keepalive_expiry=keepalive_expiry,
)
timeout = httpx.Timeout(
connect=connect_timeout,
read=read_timeout,
write=30.0,
pool=5.0,
)
self._client = httpx.AsyncClient(
limits=limits,
timeout=timeout,
http2=True,
follow_redirects=True,
headers={
"User-Agent": DEFAULT_USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
},
)
logger.debug(
"HTTP client created with limits: max=%d, keepalive=%d",
max_connections,
max_keepalive_connections,
)
return self._client
async def close(self) -> None:
"""Close the HTTP client and release resources."""
if self._client is not None and not self._client.is_closed:
logger.info("Closing HTTP client pool")
await self._client.aclose()
self._client = None
@property
def is_connected(self) -> bool:
"""Check if client is active and connected."""
return self._client is not None and not self._client.is_closed
# Global pool instance
_pool = HTTPClientPool()
async def get_http_client() -> httpx.AsyncClient:
"""
Get the shared HTTP client instance.
This is the primary way to access the HTTP pool.
Returns:
Configured httpx.AsyncClient for making requests
Example:
client = await get_http_client()
response = await client.get("https://medium.com/...")
"""
return await _pool.get_client()
async def close_http_pool() -> None:
"""Close the global HTTP pool (call on shutdown)."""
await _pool.close()
@asynccontextmanager
async def http_session() -> AsyncGenerator[httpx.AsyncClient, None]:
"""
Context manager for HTTP operations.
Provides a client with automatic error logging.
Does NOT close the client (it's a shared pool).
Example:
async with http_session() as client:
response = await client.get("https://example.com")
data = response.text
"""
client = await get_http_client()
try:
yield client
except httpx.HTTPStatusError as e:
logger.error(
"HTTP error: status=%d url=%s",
e.response.status_code,
str(e.request.url),
)
raise
except httpx.RequestError as e:
logger.error(
"HTTP request error: %s url=%s",
type(e).__name__,
str(e.request.url) if e.request else "unknown",
)
raise
@asynccontextmanager
async def new_http_client(**kwargs: object) -> AsyncGenerator[httpx.AsyncClient, None]:
"""
Create a new temporary HTTP client (not from pool).
Use this when you need isolated client settings.
The client is closed when the context exits.
Example:
async with new_http_client(timeout=60.0) as client:
response = await client.get("https://slow-api.example.com")
"""
client = httpx.AsyncClient(**kwargs) # type: ignore
try:
yield client
finally:
await client.aclose()
|