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
« 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
6Shared HTTP Client Service.
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:
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
16Performance benchmarks show ~20x throughput improvement vs per-request clients.
18Usage:
19 from mcpgateway.services.http_client_service import get_http_client
21 # Get the shared client for making requests
22 client = await get_http_client()
23 response = await client.get("https://example.com/api")
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")
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"""
41# Future
42from __future__ import annotations
44# Standard
45import asyncio
46from contextlib import asynccontextmanager
47import logging
48import ssl
49from typing import AsyncIterator, Optional
51# Third-Party
52import httpx
54logger = logging.getLogger(__name__)
57class SharedHttpClient:
58 """
59 Singleton wrapper for a shared httpx.AsyncClient.
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.
65 The client is initialized lazily on first access and shut down during
66 application shutdown via the FastAPI lifespan.
67 """
69 _instance: Optional["SharedHttpClient"] = None
70 _lock: asyncio.Lock = asyncio.Lock()
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
78 @classmethod
79 async def get_instance(cls) -> "SharedHttpClient":
80 """
81 Get or create the singleton instance.
83 Thread-safe initialization using asyncio.Lock.
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
96 async def _initialize(self) -> None:
97 """
98 Initialize the HTTP client with configured limits and timeouts.
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
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 )
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 )
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
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 )
135 @property
136 def client(self) -> httpx.AsyncClient:
137 """
138 Get the shared HTTP client.
140 Returns:
141 httpx.AsyncClient: The shared client instance.
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
150 def get_pool_stats(self) -> dict[str, int]:
151 """
152 Get connection pool configuration limits.
154 Returns:
155 dict: Connection pool limit metrics:
156 - max_connections: Maximum allowed connections
157 - max_keepalive: Maximum idle connections to retain
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 {}
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 }
173 # Fallback if _limits somehow not set (should never happen)
174 return {}
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")
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
193# Module-level convenience functions
196async def get_http_client() -> httpx.AsyncClient:
197 """
198 Get the shared HTTP client for making requests.
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.
204 Returns:
205 httpx.AsyncClient: The shared client instance.
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
215def get_http_limits() -> httpx.Limits:
216 """
217 Get configured HTTPX Limits for use with custom clients.
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.
222 Returns:
223 httpx.Limits: Configured limits from settings.
224 """
225 # First-Party
226 from mcpgateway.config import settings # pylint: disable=import-outside-toplevel
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 )
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.
244 Allows overriding specific timeout values while using defaults for others.
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).
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
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 )
266def get_admin_timeout() -> httpx.Timeout:
267 """
268 Get a shorter timeout for admin UI operations.
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).
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
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 )
287def get_default_verify() -> bool:
288 """
289 Get the default SSL verification setting based on skip_ssl_verify config.
291 Use this when creating factory clients that should respect the global
292 skip_ssl_verify setting when no custom SSL context is provided.
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
300 return not settings.skip_ssl_verify
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.
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.
321 For most cases, prefer get_http_client() which reuses connections.
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).
334 Yields:
335 httpx.AsyncClient: A new isolated client instance.
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
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 )
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()
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