Source code for klea_utils.api.server
#!/usr/bin/env python3
"""
Shared server launcher factory for Klea packages.
File: klea_utils/api/server.py
Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
"""
import subprocess
import sys
import time
from collections.abc import Iterator
from contextlib import contextmanager
from subprocess import Popen
from urllib.parse import urlsplit
import typer
[docs]
def make_serve_app(app_module: str, default_port: int = 8005) -> typer.Typer:
"""Create a Typer app that runs uvicorn on the given *app_module*.
The module string should be the importable path to a FastAPI ``app``
instance, e.g. ``"klea_rag.api.main:app"``.
:param app_module: Uvicorn module string
:param default_port: Default port number
:returns: A :class:`typer.Typer` app for use as a CLI entry point
"""
serve_app = typer.Typer()
@serve_app.command()
def serve(
host: str = "127.0.0.1",
port: int = default_port,
dev: bool = typer.Option(
False, "--dev", help="Enable auto-reload (like fastapi dev)"
),
):
"""Run the API server."""
# Lazy: uvicorn pulls in starlette/httptools/websockets etc.
import uvicorn
uvicorn.run(
app_module,
host=host,
port=port,
reload=dev,
)
return serve_app
[docs]
def split_server_url(url: str, default_port: int = 8005) -> tuple[str, int]:
"""Return ``(host, port)`` parsed from *url*.
Falls back to ``127.0.0.1`` and *default_port* when the URL does not
carry a hostname or port.
:param url: Server URL (e.g. ``http://127.0.0.1:8005``)
:param default_port: Port to use when the URL omits one
:returns: ``(host, port)`` suitable for binding a local server
"""
parts = urlsplit(url)
host = parts.hostname or "127.0.0.1"
try:
port = parts.port or default_port
except ValueError:
port = default_port
return host, port
[docs]
def is_loopback_host(host: str) -> bool:
"""Return ``True`` when *host* refers to the local machine.
:param host: Hostname from a server URL (e.g. ``"127.0.0.1"``)
:returns: ``True`` for loopback addresses, ``False`` otherwise
"""
return host.lower() in {"127.0.0.1", "localhost", "::1"}
[docs]
@contextmanager
def spawn_server(
app_module: str,
host: str = "127.0.0.1",
port: int = 8005,
timeout: float = 180.0,
) -> Iterator[Popen | None]:
"""Context manager that runs an API server in a subprocess.
If a healthy server is already listening at ``host:port`` (probed via
``/health/ready``), nothing is spawned and ``None`` is yielded, so the
caller does not own the server's lifecycle. Otherwise a uvicorn
subprocess is spawned (stdout and stderr inherited so startup errors and
app output are visible; access logs are disabled to keep the shared
terminal clean, with full logging preserved in the server's rotating log
file), readiness is waited on, and the subprocess is terminated when the
``with`` block exits.
:param app_module: Uvicorn module string (e.g. ``"klea_rag.api.main:app"``)
:param host: Host to bind
:param port: Port to bind
:param timeout: Total seconds to wait for readiness after spawning
:returns: The spawned :class:`subprocess.Popen` (or ``None`` if an
existing server was reused)
"""
# Lazy: asyncio/httpx and the api utils pull in heavy deps; keep --help fast.
import asyncio
import httpx
from klea_utils.api.utils import check_api_is_ready
health_url = f"http://{host}:{port}/health/ready"
def _probe_once() -> bool:
try:
asyncio.run(check_api_is_ready(health_url, attempts=1))
return True
except (httpx.HTTPError, OSError):
return False
if _probe_once():
yield None
return
proc = subprocess.Popen(
[
sys.executable,
"-m",
"uvicorn",
app_module,
"--host",
host,
"--port",
str(port),
"--no-access-log",
],
)
try:
# A short fast-fail window catches an instantly-crashed server
# (bad module path, port already in use) so the error surfaces
# immediately instead of after the full retry timeout.
for _ in range(5):
if proc.poll() is not None:
raise RuntimeError(
f"Server process exited immediately (code {proc.returncode}). "
"Check the server's log file, or run the server directly "
"with 'klea-rag-serve serve' / 'klea-serve serve' "
"to see the error."
)
if _probe_once():
break
time.sleep(1.0)
else:
try:
asyncio.run(check_api_is_ready(health_url, timeout=timeout))
except httpx.HTTPError as exc:
raise RuntimeError(
f"Server at {health_url} did not become ready within {timeout:g}s. "
"Check the server's log file, or run the server directly "
"with 'klea-rag-serve serve' / 'klea-serve serve' "
"to see the error."
) from exc
yield proc
finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()