Source code for klea_utils.api.utils

#!/usr/bin/env python3
"""
Utility functions for the Klea API layer.

File: klea_utils/api/utils.py

Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
"""

import asyncio
import logging

from pydantic import HttpUrl
from pydantic import ValidationError as PydanticValidationError
from tenacity import (
    AsyncRetrying,
    retry_if_exception,
    stop_after_attempt,
    stop_after_delay,
    wait_random_exponential,
)

logger = logging.getLogger(__name__)


[docs] def validate_url(value: str) -> str: """Return *value* if it is a valid HTTP(S) URL, else raise ``ValueError``.""" try: HttpUrl(value) except PydanticValidationError: raise ValueError(f"'{value}' is not a valid HTTP(S) URL") return value
def _make_retryer_httpx( attempts: int | None = None, timeout: float = 180.0 ) -> AsyncRetrying: """Create an ``AsyncRetrying`` that retries transient httpx errors. Retries transient httpx failures: connection errors, read errors, timeouts, and HTTP 5xx/429 responses. Other 4xx client errors are not retried. :param attempts: If set, bound the number of probe attempts. Takes precedence over *timeout* (useful for fast single-shot probes). :param timeout: Total wall-clock seconds to keep probing when *attempts* is unset. Generous by default so a server that is slow to initialize (MCP servers, embedding model downloads) is not given up on prematurely. :returns: A configured :class:`tenacity.AsyncRetrying` instance """ # Import lazily so importing this module never requires httpx. import httpx def _is_retryable(exc: BaseException) -> bool: if isinstance(exc, (asyncio.TimeoutError, httpx.TransportError)): # asyncio.TimeoutError for client-side total timeouts; # httpx.TransportError covers ConnectError/ReadError/ReadTimeout. return True if isinstance(exc, httpx.HTTPStatusError): # 429 (rate limit) and 5xx (server side) are transient; other # 4xx are client mistakes and should not be retried. return exc.response.status_code == 429 or exc.response.status_code >= 500 return False if attempts is not None: stop = stop_after_attempt(attempts) else: stop = stop_after_delay(timeout) return AsyncRetrying( wait=wait_random_exponential(multiplier=1, max=10), stop=stop, retry=retry_if_exception(_is_retryable), reraise=True, ) async def _get_ready(url: str) -> dict: """GET the health endpoint and return its JSON, raising on non-2xx.""" # Deferred so importing this module never requires httpx. import httpx async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as client: response = await client.get(url) response.raise_for_status() return response.json()
[docs] async def check_api_is_ready( url: str, attempts: int | None = None, timeout: float = 180.0 ): """Exponentially back off checking that the API is ready. :param url: Health check endpoint URL :param attempts: If set, maximum number of probe attempts (overrides *timeout*) :param timeout: Total wall-clock seconds to keep probing when *attempts* is unset """ retryer = _make_retryer_httpx(attempts, timeout) logger.debug("Probing API readiness at %s (attempts=%s)", url, attempts) try: result = await retryer(_get_ready, url) except Exception as e: logger.warning("API not ready at %s after probes: %s", url, e) raise logger.debug("API ready at %s", url) return result