Coverage for mcpgateway / services / http_client_service.py: 100%

77 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-02-11 07:10 +0000

1# -*- coding: utf-8 -*- 

2"""Location: ./mcpgateway/services/http_client_service.py 

3Copyright 2025 

4SPDX-License-Identifier: Apache-2.0 

5 

6Shared HTTP Client Service. 

7 

8This module provides a singleton httpx.AsyncClient that is shared across all 

9services in MCP Gateway. Using a shared client instead of per-request clients 

10provides significant performance benefits: 

11 

12- Connection reuse: Avoids TCP handshake and TLS negotiation per request 

13- Connection pooling: Manages concurrent connections efficiently 

14- Configurable limits: Prevents connection exhaustion under high load 

15 

16Performance benchmarks show ~20x throughput improvement vs per-request clients. 

17 

18Usage: 

19 from mcpgateway.services.http_client_service import get_http_client 

20 

21 # Get the shared client for making requests 

22 client = await get_http_client() 

23 response = await client.get("https://example.com/api") 

24 

25 # For requests needing isolated TLS/auth context (rare): 

26 async with get_isolated_http_client(verify=custom_ssl_context) as client: 

27 response = await client.get("https://example.com/api") 

28 

29Configuration (environment variables): 

30 HTTPX_MAX_CONNECTIONS: Maximum concurrent connections (default: 200) 

31 HTTPX_MAX_KEEPALIVE_CONNECTIONS: Idle connections to retain (default: 100) 

32 HTTPX_KEEPALIVE_EXPIRY: Idle connection timeout in seconds (default: 30) 

33 HTTPX_CONNECT_TIMEOUT: Connection timeout in seconds (default: 5) 

34 HTTPX_READ_TIMEOUT: Read timeout in seconds (default: 120, high for slow MCP tools) 

35 HTTPX_WRITE_TIMEOUT: Write timeout in seconds (default: 30) 

36 HTTPX_POOL_TIMEOUT: Pool wait timeout in seconds (default: 10) 

37 HTTPX_HTTP2_ENABLED: Enable HTTP/2 (default: false) 

38 HTTPX_ADMIN_READ_TIMEOUT: Read timeout for admin operations (default: 30) 

39""" 

40 

41# Future 

42from __future__ import annotations 

43 

44# Standard 

45import asyncio 

46from contextlib import asynccontextmanager 

47import logging 

48import ssl 

49from typing import AsyncIterator, Optional 

50 

51# Third-Party 

52import httpx 

53 

54logger = logging.getLogger(__name__) 

55 

56 

57class SharedHttpClient: 

58 """ 

59 Singleton wrapper for a shared httpx.AsyncClient. 

60 

61 All callers share the same client instance and its internal connection pool. 

62 This avoids the overhead of creating new clients per request while providing 

63 configurable connection limits. 

64 

65 The client is initialized lazily on first access and shut down during 

66 application shutdown via the FastAPI lifespan. 

67 """ 

68 

69 _instance: Optional["SharedHttpClient"] = None 

70 _lock: asyncio.Lock = asyncio.Lock() 

71 

72 def __init__(self) -> None: 

73 """Initialize the SharedHttpClient wrapper (not the actual client).""" 

74 self._client: Optional[httpx.AsyncClient] = None 

75 self._initialized: bool = False 

76 self._limits: Optional[httpx.Limits] = None 

77 

78 @classmethod 

79 async def get_instance(cls) -> "SharedHttpClient": 

80 """ 

81 Get or create the singleton instance. 

82 

83 Thread-safe initialization using asyncio.Lock. 

84 

85 Returns: 

86 SharedHttpClient: The singleton instance with initialized client. 

87 """ 

88 if cls._instance is None or not cls._instance._initialized: # pylint: disable=protected-access 

89 async with cls._lock: 

90 if cls._instance is None: 

91 cls._instance = cls() 

92 if not cls._instance._initialized: # pylint: disable=protected-access 

93 await cls._instance._initialize() # pylint: disable=protected-access 

94 return cls._instance 

95 

96 async def _initialize(self) -> None: 

97 """ 

98 Initialize the HTTP client with configured limits and timeouts. 

99 

100 Reads configuration from settings and creates the shared AsyncClient. 

101 """ 

102 # Import here to avoid circular imports 

103 # First-Party 

104 from mcpgateway.config import settings # pylint: disable=import-outside-toplevel 

105 

106 self._limits = httpx.Limits( 

107 max_connections=settings.httpx_max_connections, 

108 max_keepalive_connections=settings.httpx_max_keepalive_connections, 

109 keepalive_expiry=settings.httpx_keepalive_expiry, 

110 ) 

111 

112 timeout = httpx.Timeout( 

113 connect=settings.httpx_connect_timeout, 

114 read=settings.httpx_read_timeout, 

115 write=settings.httpx_write_timeout, 

116 pool=settings.httpx_pool_timeout, 

117 ) 

118 

119 self._client = httpx.AsyncClient( 

120 limits=self._limits, 

121 timeout=timeout, 

122 http2=settings.httpx_http2_enabled, 

123 follow_redirects=True, 

124 verify=not settings.skip_ssl_verify, 

125 ) 

126 self._initialized = True 

127 

128 logger.info( 

129 "Shared HTTP client initialized: max_connections=%d, keepalive=%d, http2=%s", 

130 settings.httpx_max_connections, 

131 settings.httpx_max_keepalive_connections, 

132 settings.httpx_http2_enabled, 

133 ) 

134 

135 @property 

136 def client(self) -> httpx.AsyncClient: 

137 """ 

138 Get the shared HTTP client. 

139 

140 Returns: 

141 httpx.AsyncClient: The shared client instance. 

142 

143 Raises: 

144 RuntimeError: If the client has not been initialized. 

145 """ 

146 if self._client is None: 

147 raise RuntimeError("SharedHttpClient not initialized. Call get_instance() first.") 

148 return self._client 

149 

150 def get_pool_stats(self) -> dict[str, int]: 

151 """ 

152 Get connection pool configuration limits. 

153 

154 Returns: 

155 dict: Connection pool limit metrics: 

156 - max_connections: Maximum allowed connections 

157 - max_keepalive: Maximum idle connections to retain 

158 

159 Note: 

160 Returns empty dict if client is not initialized. 

161 Actual connection counts are not exposed by httpx. 

162 """ 

163 if self._client is None: 

164 return {} 

165 

166 # Return pool configuration limits (actual connection counts not exposed by httpx) 

167 if self._limits is not None: 

168 return { 

169 "max_connections": self._limits.max_connections, 

170 "max_keepalive": self._limits.max_keepalive_connections, 

171 } 

172 

173 # Fallback if _limits somehow not set (should never happen) 

174 return {} 

175 

176 async def close(self) -> None: 

177 """Close the shared HTTP client and release all connections.""" 

178 if self._client: 

179 await self._client.aclose() 

180 self._client = None 

181 self._initialized = False 

182 self._limits = None 

183 logger.info("Shared HTTP client closed") 

184 

185 @classmethod 

186 async def shutdown(cls) -> None: 

187 """Shutdown the singleton instance during application shutdown.""" 

188 if cls._instance: 

189 await cls._instance.close() 

190 cls._instance = None 

191 

192 

193# Module-level convenience functions 

194 

195 

196async def get_http_client() -> httpx.AsyncClient: 

197 """ 

198 Get the shared HTTP client for making requests. 

199 

200 This is the primary way to obtain an HTTP client in the application. 

201 The client is shared across all callers and manages connection pooling 

202 automatically. 

203 

204 Returns: 

205 httpx.AsyncClient: The shared client instance. 

206 

207 Example: 

208 client = await get_http_client() 

209 response = await client.post(url, json=data, headers={"X-Custom": "value"}) 

210 """ 

211 instance = await SharedHttpClient.get_instance() 

212 return instance.client 

213 

214 

215def get_http_limits() -> httpx.Limits: 

216 """ 

217 Get configured HTTPX Limits for use with custom clients. 

218 

219 Use this when you need to create a separate client (e.g., for SSE/streaming 

220 with mcp-sdk) but want to use the same connection limits as the shared client. 

221 

222 Returns: 

223 httpx.Limits: Configured limits from settings. 

224 """ 

225 # First-Party 

226 from mcpgateway.config import settings # pylint: disable=import-outside-toplevel 

227 

228 return httpx.Limits( 

229 max_connections=settings.httpx_max_connections, 

230 max_keepalive_connections=settings.httpx_max_keepalive_connections, 

231 keepalive_expiry=settings.httpx_keepalive_expiry, 

232 ) 

233 

234 

235def get_http_timeout( 

236 read_timeout: Optional[float] = None, 

237 connect_timeout: Optional[float] = None, 

238 write_timeout: Optional[float] = None, 

239 pool_timeout: Optional[float] = None, 

240) -> httpx.Timeout: 

241 """ 

242 Get configured HTTPX Timeout for use with custom clients. 

243 

244 Allows overriding specific timeout values while using defaults for others. 

245 

246 Args: 

247 read_timeout: Override for read timeout (seconds). 

248 connect_timeout: Override for connect timeout (seconds). 

249 write_timeout: Override for write timeout (seconds). 

250 pool_timeout: Override for pool timeout (seconds). 

251 

252 Returns: 

253 httpx.Timeout: Configured timeout from settings with optional overrides. 

254 """ 

255 # First-Party 

256 from mcpgateway.config import settings # pylint: disable=import-outside-toplevel 

257 

258 return httpx.Timeout( 

259 connect=connect_timeout if connect_timeout is not None else settings.httpx_connect_timeout, 

260 read=read_timeout if read_timeout is not None else settings.httpx_read_timeout, 

261 write=write_timeout if write_timeout is not None else settings.httpx_write_timeout, 

262 pool=pool_timeout if pool_timeout is not None else settings.httpx_pool_timeout, 

263 ) 

264 

265 

266def get_admin_timeout() -> httpx.Timeout: 

267 """ 

268 Get a shorter timeout for admin UI operations. 

269 

270 Use this for operations where fast failure is preferred over waiting for slow 

271 upstreams (e.g., model list fetching, health checks, admin page data). 

272 

273 Returns: 

274 httpx.Timeout: Timeout configured for admin operations (shorter read timeout). 

275 """ 

276 # First-Party 

277 from mcpgateway.config import settings # pylint: disable=import-outside-toplevel 

278 

279 return httpx.Timeout( 

280 connect=settings.httpx_connect_timeout, 

281 read=settings.httpx_admin_read_timeout, 

282 write=settings.httpx_write_timeout, 

283 pool=settings.httpx_pool_timeout, 

284 ) 

285 

286 

287def get_default_verify() -> bool: 

288 """ 

289 Get the default SSL verification setting based on skip_ssl_verify config. 

290 

291 Use this when creating factory clients that should respect the global 

292 skip_ssl_verify setting when no custom SSL context is provided. 

293 

294 Returns: 

295 bool: True if SSL should be verified, False if skip_ssl_verify is enabled. 

296 """ 

297 # First-Party 

298 from mcpgateway.config import settings # pylint: disable=import-outside-toplevel 

299 

300 return not settings.skip_ssl_verify 

301 

302 

303@asynccontextmanager 

304async def get_isolated_http_client( 

305 timeout: Optional[float] = None, 

306 headers: Optional[dict[str, str]] = None, 

307 verify: Optional[bool | ssl.SSLContext] = None, 

308 auth: Optional[httpx.Auth] = None, 

309 http2: Optional[bool] = None, 

310 connect_timeout: Optional[float] = None, 

311 write_timeout: Optional[float] = None, 

312 pool_timeout: Optional[float] = None, 

313) -> AsyncIterator[httpx.AsyncClient]: 

314 """ 

315 Create an isolated HTTP client with custom settings. 

316 

317 WARNING: This creates a NEW client with its own connection pool. 

318 Connections are NOT shared with the singleton. Use sparingly for cases 

319 requiring custom TLS context or authentication that can't use the shared client. 

320 

321 For most cases, prefer get_http_client() which reuses connections. 

322 

323 Args: 

324 timeout: Optional read timeout override (seconds). 

325 headers: Optional default headers for all requests. 

326 verify: SSL verification setting (True, False, SSLContext, or None). 

327 If None, uses skip_ssl_verify setting to determine default. 

328 auth: Optional authentication handler. 

329 http2: Override HTTP/2 setting (default: use settings). 

330 connect_timeout: Optional connect timeout override (seconds). 

331 write_timeout: Optional write timeout override (seconds). 

332 pool_timeout: Optional pool timeout override (seconds). 

333 

334 Yields: 

335 httpx.AsyncClient: A new isolated client instance. 

336 

337 Example: 

338 async with get_isolated_http_client(verify=custom_ssl_context) as client: 

339 response = await client.get("https://example.com/api") 

340 """ 

341 # First-Party 

342 from mcpgateway.config import settings # pylint: disable=import-outside-toplevel 

343 

344 limits = get_http_limits() 

345 timeout_config = get_http_timeout( 

346 read_timeout=timeout, 

347 connect_timeout=connect_timeout, 

348 write_timeout=write_timeout, 

349 pool_timeout=pool_timeout, 

350 ) 

351 

352 # Use skip_ssl_verify setting if no explicit verify value provided 

353 effective_verify: bool | ssl.SSLContext = verify if verify is not None else get_default_verify() 

354 

355 async with httpx.AsyncClient( 

356 limits=limits, 

357 timeout=timeout_config, 

358 headers=headers, 

359 verify=effective_verify, 

360 auth=auth, 

361 http2=http2 if http2 is not None else settings.httpx_http2_enabled, 

362 follow_redirects=True, 

363 ) as client: 

364 yield client