|
6 | 6 | import os |
7 | 7 | import ssl |
8 | 8 | import warnings |
| 9 | +from asyncio import Lock |
9 | 10 | from base64 import urlsafe_b64encode |
10 | 11 | from hashlib import sha256 |
11 | 12 | from hmac import new as hmac_new |
|
27 | 28 | DEFAULT_KEEPALIVE_EXPIRY_SECS = 1.0 |
28 | 29 | DEFAULT_MAX_CONNECTIONS = 100 |
29 | 30 | DEFAULT_MAX_KEEPALIVE_CONNECTIONS = 20 |
| 31 | +DEFAULT_CLIENT_POOL_SIZE = 1 |
| 32 | +LUNA_KEEPALIVE_EXPIRY_ENV = "GALILEO_LUNA_KEEPALIVE_EXPIRY_SECONDS" |
| 33 | +LUNA_MAX_CONNECTIONS_ENV = "GALILEO_LUNA_MAX_CONNECTIONS" |
| 34 | +LUNA_MAX_KEEPALIVE_CONNECTIONS_ENV = "GALILEO_LUNA_MAX_KEEPALIVE_CONNECTIONS" |
| 35 | +LUNA_CLIENT_POOL_SIZE_ENV = "GALILEO_LUNA_CLIENT_POOL_SIZE" |
30 | 36 | PUBLIC_SCORER_INVOKE_PATH = "/scorers/invoke" |
31 | 37 | INTERNAL_SCORER_INVOKE_PATH = "/internal/scorers/invoke" |
32 | 38 | AuthMode = Literal["public", "internal"] |
@@ -78,6 +84,54 @@ def _env_auth_mode() -> AuthMode | None: |
78 | 84 | raise ValueError("GALILEO_LUNA_AUTH_MODE must be either 'public' or 'internal'.") |
79 | 85 |
|
80 | 86 |
|
| 87 | +def _load_float_env(env_name: str, default: float) -> float: |
| 88 | + raw = os.getenv(env_name) |
| 89 | + if raw is None or raw.strip() == "": |
| 90 | + return default |
| 91 | + try: |
| 92 | + return float(raw) |
| 93 | + except ValueError as exc: |
| 94 | + raise ValueError(f"{env_name}={raw!r} is not a number.") from exc |
| 95 | + |
| 96 | + |
| 97 | +def _load_int_env(env_name: str, default: int) -> int: |
| 98 | + raw = os.getenv(env_name) |
| 99 | + if raw is None or raw.strip() == "": |
| 100 | + return default |
| 101 | + try: |
| 102 | + return int(raw) |
| 103 | + except ValueError as exc: |
| 104 | + raise ValueError(f"{env_name}={raw!r} is not an integer.") from exc |
| 105 | + |
| 106 | + |
| 107 | +def _validate_connection_config( |
| 108 | + *, |
| 109 | + keepalive_expiry_seconds: float, |
| 110 | + max_connections: int, |
| 111 | + max_keepalive_connections: int, |
| 112 | + client_pool_size: int, |
| 113 | +) -> None: |
| 114 | + if keepalive_expiry_seconds < 0: |
| 115 | + raise ValueError( |
| 116 | + f"{LUNA_KEEPALIVE_EXPIRY_ENV}={keepalive_expiry_seconds} " |
| 117 | + "must be greater than or equal to 0." |
| 118 | + ) |
| 119 | + if max_connections <= 0: |
| 120 | + raise ValueError(f"{LUNA_MAX_CONNECTIONS_ENV}={max_connections} must be greater than 0.") |
| 121 | + if max_keepalive_connections < 0: |
| 122 | + raise ValueError( |
| 123 | + f"{LUNA_MAX_KEEPALIVE_CONNECTIONS_ENV}={max_keepalive_connections} " |
| 124 | + "must be greater than or equal to 0." |
| 125 | + ) |
| 126 | + if max_keepalive_connections > max_connections: |
| 127 | + raise ValueError( |
| 128 | + f"{LUNA_MAX_KEEPALIVE_CONNECTIONS_ENV}={max_keepalive_connections} " |
| 129 | + f"must be less than or equal to {LUNA_MAX_CONNECTIONS_ENV}={max_connections}." |
| 130 | + ) |
| 131 | + if client_pool_size <= 0: |
| 132 | + raise ValueError(f"{LUNA_CLIENT_POOL_SIZE_ENV}={client_pool_size} must be greater than 0.") |
| 133 | + |
| 134 | + |
81 | 135 | def _as_float_or_none(value: JSONValue) -> float | None: |
82 | 136 | if isinstance(value, bool) or value is None: |
83 | 137 | return None |
@@ -184,6 +238,10 @@ class GalileoLunaClient: |
184 | 238 | GALILEO_API_URL: Galileo API URL fallback. |
185 | 239 | GALILEO_LUNA_CA_FILE: CA bundle used to verify the scorer API endpoint, for |
186 | 240 | deployments whose API serves an internally-issued TLS certificate. |
| 241 | + GALILEO_LUNA_KEEPALIVE_EXPIRY_SECONDS: HTTP pooled connection expiry. |
| 242 | + GALILEO_LUNA_MAX_CONNECTIONS: Maximum outbound HTTP connections. |
| 243 | + GALILEO_LUNA_MAX_KEEPALIVE_CONNECTIONS: Maximum idle pooled HTTP connections. |
| 244 | + GALILEO_LUNA_CLIENT_POOL_SIZE: Number of outbound HTTP clients to rotate across. |
187 | 245 | GALILEO_CONSOLE_URL: Galileo Console URL (optional, defaults to production). |
188 | 246 | """ |
189 | 247 |
|
@@ -235,7 +293,26 @@ def __init__( |
235 | 293 | self.api_base = self._resolve_api_base(api_url) |
236 | 294 | self.ca_file = (ca_file or os.getenv("GALILEO_LUNA_CA_FILE") or "").strip() or None |
237 | 295 | self._ssl_context = self._load_ssl_context(self.ca_file) |
| 296 | + self.keepalive_expiry_seconds = _load_float_env( |
| 297 | + LUNA_KEEPALIVE_EXPIRY_ENV, DEFAULT_KEEPALIVE_EXPIRY_SECS |
| 298 | + ) |
| 299 | + self.max_connections = _load_int_env(LUNA_MAX_CONNECTIONS_ENV, DEFAULT_MAX_CONNECTIONS) |
| 300 | + self.max_keepalive_connections = _load_int_env( |
| 301 | + LUNA_MAX_KEEPALIVE_CONNECTIONS_ENV, DEFAULT_MAX_KEEPALIVE_CONNECTIONS |
| 302 | + ) |
| 303 | + self.client_pool_size = _load_int_env( |
| 304 | + LUNA_CLIENT_POOL_SIZE_ENV, DEFAULT_CLIENT_POOL_SIZE |
| 305 | + ) |
| 306 | + _validate_connection_config( |
| 307 | + keepalive_expiry_seconds=self.keepalive_expiry_seconds, |
| 308 | + max_connections=self.max_connections, |
| 309 | + max_keepalive_connections=self.max_keepalive_connections, |
| 310 | + client_pool_size=self.client_pool_size, |
| 311 | + ) |
238 | 312 | self._client: httpx.AsyncClient | None = None |
| 313 | + self._clients: list[httpx.AsyncClient] = [] |
| 314 | + self._next_client_index = 0 |
| 315 | + self._client_lock = Lock() |
239 | 316 | logger.info("[GalileoLunaClient] Auth mode selected: %s", self.auth_mode) |
240 | 317 |
|
241 | 318 | def _resolve_api_base(self, api_url: str | None) -> str: |
@@ -316,26 +393,48 @@ def _derive_api_url(self, console_url: str) -> str: |
316 | 393 | parts._replace(netloc=parts.netloc.replace(host, new_host, 1)) |
317 | 394 | ) |
318 | 395 |
|
| 396 | + def _create_client(self) -> httpx.AsyncClient: |
| 397 | + """Create an HTTP client with the configured auth, TLS, and connection limits.""" |
| 398 | + headers = {"Content-Type": "application/json"} |
| 399 | + if self.auth_mode == "public" and self.api_key is not None: |
| 400 | + headers["Galileo-API-Key"] = self.api_key |
| 401 | + verify: ssl.SSLContext | bool = ( |
| 402 | + self._ssl_context if self._ssl_context is not None else True |
| 403 | + ) |
| 404 | + return httpx.AsyncClient( |
| 405 | + headers=headers, |
| 406 | + timeout=httpx.Timeout(DEFAULT_TIMEOUT_SECS), |
| 407 | + limits=httpx.Limits( |
| 408 | + max_connections=self.max_connections, |
| 409 | + max_keepalive_connections=self.max_keepalive_connections, |
| 410 | + keepalive_expiry=self.keepalive_expiry_seconds, |
| 411 | + ), |
| 412 | + verify=verify, |
| 413 | + ) |
| 414 | + |
| 415 | + def _select_pooled_client(self) -> httpx.AsyncClient: |
| 416 | + """Select the next pooled client while holding the client state lock.""" |
| 417 | + client = self._clients[self._next_client_index % len(self._clients)] |
| 418 | + self._next_client_index = (self._next_client_index + 1) % len(self._clients) |
| 419 | + return client |
| 420 | + |
319 | 421 | async def _get_client(self) -> httpx.AsyncClient: |
320 | | - """Get or create the HTTP client.""" |
321 | | - if self._client is None or self._client.is_closed: |
322 | | - headers = {"Content-Type": "application/json"} |
323 | | - if self.auth_mode == "public" and self.api_key is not None: |
324 | | - headers["Galileo-API-Key"] = self.api_key |
325 | | - verify: ssl.SSLContext | bool = ( |
326 | | - self._ssl_context if self._ssl_context is not None else True |
327 | | - ) |
328 | | - self._client = httpx.AsyncClient( |
329 | | - headers=headers, |
330 | | - timeout=httpx.Timeout(DEFAULT_TIMEOUT_SECS), |
331 | | - limits=httpx.Limits( |
332 | | - max_connections=DEFAULT_MAX_CONNECTIONS, |
333 | | - max_keepalive_connections=DEFAULT_MAX_KEEPALIVE_CONNECTIONS, |
334 | | - keepalive_expiry=DEFAULT_KEEPALIVE_EXPIRY_SECS, |
335 | | - ), |
336 | | - verify=verify, |
337 | | - ) |
338 | | - return self._client |
| 422 | + """Get or create the next HTTP client.""" |
| 423 | + async with self._client_lock: |
| 424 | + self._clients = [client for client in self._clients if not client.is_closed] |
| 425 | + |
| 426 | + if self.client_pool_size == 1: |
| 427 | + if self._client is not None and not self._client.is_closed: |
| 428 | + return self._client |
| 429 | + self._client = self._clients[0] if self._clients else self._create_client() |
| 430 | + self._clients = [self._client] |
| 431 | + return self._client |
| 432 | + |
| 433 | + self._client = None |
| 434 | + while len(self._clients) < self.client_pool_size: |
| 435 | + self._clients.append(self._create_client()) |
| 436 | + |
| 437 | + return self._select_pooled_client() |
339 | 438 |
|
340 | 439 | def _endpoint_and_headers( |
341 | 440 | self, |
@@ -431,9 +530,23 @@ async def invoke( |
431 | 530 |
|
432 | 531 | async def close(self) -> None: |
433 | 532 | """Close the HTTP client and release resources.""" |
434 | | - if self._client is not None: |
435 | | - await self._client.aclose() |
| 533 | + async with self._client_lock: |
| 534 | + clients: list[httpx.AsyncClient] = [] |
| 535 | + seen_client_ids: set[int] = set() |
| 536 | + if self._client is not None: |
| 537 | + clients.append(self._client) |
| 538 | + seen_client_ids.add(id(self._client)) |
436 | 539 | self._client = None |
| 540 | + for client in self._clients: |
| 541 | + if id(client) not in seen_client_ids: |
| 542 | + clients.append(client) |
| 543 | + seen_client_ids.add(id(client)) |
| 544 | + self._clients = [] |
| 545 | + self._next_client_index = 0 |
| 546 | + |
| 547 | + for client in clients: |
| 548 | + if not client.is_closed: |
| 549 | + await client.aclose() |
437 | 550 |
|
438 | 551 | async def __aenter__(self) -> GalileoLunaClient: |
439 | 552 | """Async context manager entry.""" |
|
0 commit comments