Source code for klea_utils.api.app
#!/usr/bin/env python3
"""
Shared FastAPI app factory for Klea packages.
File: klea_utils/api/app.py
Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
"""
import logging
from collections.abc import Callable
from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI
logger = logging.getLogger(__name__)
from klea_utils.api.sessions_db import SessionStore
from klea_utils.graph.base import BaseLangGraph
from klea_utils.paths import init_dir
[docs]
def make_app(
graph_factory: Callable[[], BaseLangGraph],
title: str = "Klea API",
version: str = "0.1.0",
routers: list[APIRouter] | None = None,
) -> FastAPI:
"""Create a FastAPI instance with a standard lifespan.
The lifespan:
1. Instantiates and sets up the graph via *graph_factory*
2. Opens a persistent :class:`SessionStore` at
``{graph.paths.user_data_dir}/sessions.db`` alongside the
graph's checkpoints.
3. Stores the graph and session store on ``app.state``
:param graph_factory: Callable that returns a configured
:class:`~klea_utils.graph.base.BaseLangGraph` instance
:param title: API title (appears in OpenAPI docs)
:param version: API version (appears in OpenAPI docs)
:param routers: List of APIRouters to include on the app
:returns: Configured FastAPI app
"""
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.is_ready = False
logger.info("Klea API (%s) starting lifespan", title)
graph = graph_factory()
await graph.setup()
app.state.graph = graph
logger.info("Graph setup complete (%s)", type(graph).__name__)
db_path = init_dir(graph.paths.user_data_dir) / "sessions.db"
app.state.chat_sessions = SessionStore(str(db_path))
logger.debug("Session store ready at %s", db_path)
app.state.is_ready = True
logger.info("Klea API ready")
yield
app.state.is_ready = False
logger.info("Klea API shutting down")
# Clean up checkpointer and MCP client to avoid fd leaks / DB locks
try:
checkpointer = getattr(graph, "checkpointer", None)
if checkpointer is not None:
# AsyncSqliteSaver holds an aiosqlite Connection
conn = getattr(checkpointer, "conn", None) or getattr(
graph, "_checkpointer_conn", None
)
if conn is not None:
try:
await conn.close()
except Exception as exc: # noqa: BLE001
logger.warning(f"Failed to close checkpointer conn: {exc}")
# Some checkpointer implementations expose aclose
aclose = getattr(checkpointer, "aclose", None)
if callable(aclose):
try:
await aclose()
except Exception as exc: # noqa: BLE001
logger.warning(f"Failed to aclose checkpointer: {exc}")
except Exception as exc: # noqa: BLE001
logger.warning(f"Error during checkpointer cleanup: {exc}")
try:
mcp_client = getattr(graph, "mcp_client", None)
if mcp_client is not None:
# FastMCP Client may have async close
closer = getattr(mcp_client, "aclose", None) or getattr(
mcp_client, "close", None
)
if callable(closer):
try:
res = closer()
if hasattr(res, "__await__"):
await res
except Exception as exc: # noqa: BLE001
logger.warning(f"Failed to close MCP client: {exc}")
except Exception as exc: # noqa: BLE001
logger.warning(f"Error during MCP client cleanup: {exc}")
try:
app.state.chat_sessions.close()
except Exception as exc: # noqa: BLE001
logger.warning(f"Failed to close SessionStore: {exc}")
app = FastAPI(lifespan=lifespan, title=title, version=version)
for router in routers or []:
app.include_router(router)
return app