strat-chatbot/config/container.py
Cal Corum 43d36ce439 fix: resolve HIGH-severity issues from code review
API authentication:
- Add X-API-Secret shared-secret header validation on /chat and /stats
- /health remains public for monitoring
- Auth is a no-op when API_SECRET is empty (dev mode)

Rate limiting:
- Add per-user sliding-window rate limiter on /chat (10 req/60s default)
- Returns 429 with clear message when exceeded
- Self-cleaning memory (prunes expired entries on each check)

Exception sanitization:
- Discord bot no longer exposes raw exception text to users
- Error embeds show generic "Something went wrong" message
- Full exception details logged server-side with context
- query_chat_api RuntimeError no longer includes response body

Async correctness:
- Wrap synchronous RuleRepository.search() in run_in_executor()
  to prevent blocking the event loop during SentenceTransformer inference
- Port contract stays synchronous; service owns the async boundary

Test coverage: 101 passed, 1 skipped (11 new tests for auth + rate limiting)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 16:00:26 -05:00

165 lines
5.9 KiB
Python

"""Dependency wiring — the single place that constructs all adapters.
create_app() is the composition root for the production runtime. Tests use
the make_test_app() factory in tests/adapters/test_api.py instead (which
wires fakes directly into app.state, bypassing this module entirely).
Why a factory function instead of module-level globals
-------------------------------------------------------
- Makes the lifespan scope explicit: adapters are created inside the lifespan
context manager and torn down on exit.
- Avoids the singleton-state problems that plague import-time construction:
tests can call create_app() in isolation without shared state.
- Follows the hexagonal architecture principle that wiring is infrastructure,
not domain logic.
"""
import logging
from contextlib import asynccontextmanager
from typing import AsyncIterator
from fastapi import FastAPI
from adapters.inbound.api import router
from adapters.outbound.chroma_rules import ChromaRuleRepository
from adapters.outbound.gitea_issues import GiteaIssueTracker
from adapters.outbound.openrouter import OpenRouterLLM
from adapters.outbound.sqlite_convos import SQLiteConversationStore
from config.settings import Settings
from domain.services import ChatService
logger = logging.getLogger(__name__)
def _make_lifespan(settings: Settings):
"""Return an async context manager that owns the adapter lifecycle.
Accepts Settings so the lifespan closure captures a specific configuration
instance rather than reading from module-level state.
"""
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# ------------------------------------------------------------------
# Startup
# ------------------------------------------------------------------
logger.info("Initialising Strat-Chatbot...")
print("Initialising Strat-Chatbot...")
# Ensure required directories exist
settings.data_dir.mkdir(parents=True, exist_ok=True)
settings.chroma_dir.mkdir(parents=True, exist_ok=True)
# Vector store (synchronous adapter — no async init needed)
chroma_repo = ChromaRuleRepository(
persist_dir=settings.chroma_dir,
embedding_model=settings.embedding_model,
)
rule_count = chroma_repo.count()
print(f"ChromaDB ready at {settings.chroma_dir} ({rule_count} rules loaded)")
# SQLite conversation store
conv_store = SQLiteConversationStore(db_url=settings.db_url)
await conv_store.init_db()
print("SQLite conversation store initialised")
# LLM adapter — only constructed when an API key is present
llm: OpenRouterLLM | None = None
if settings.openrouter_api_key:
llm = OpenRouterLLM(
api_key=settings.openrouter_api_key,
model=settings.openrouter_model,
)
print(f"OpenRouter LLM ready (model: {settings.openrouter_model})")
else:
logger.warning(
"OPENROUTER_API_KEY not set — LLM adapter disabled. "
"POST /chat will return HTTP 503."
)
print(
"WARNING: OPENROUTER_API_KEY not set — "
"POST /chat will return HTTP 503."
)
# Gitea issue tracker — optional
gitea: GiteaIssueTracker | None = None
if settings.gitea_token:
gitea = GiteaIssueTracker(
token=settings.gitea_token,
owner=settings.gitea_owner,
repo=settings.gitea_repo,
base_url=settings.gitea_base_url,
)
print(
f"Gitea issue tracker ready "
f"({settings.gitea_owner}/{settings.gitea_repo})"
)
# Compose the service from its ports
service = ChatService(
rules=chroma_repo,
llm=llm, # type: ignore[arg-type] # None is handled at the router level
conversations=conv_store,
issues=gitea,
top_k_rules=settings.top_k_rules,
)
# Expose via app.state for the router's Depends helpers
app.state.chat_service = service
app.state.rule_repository = chroma_repo
app.state.api_secret = settings.api_secret
app.state.config_snapshot = {
"openrouter_model": settings.openrouter_model,
"top_k_rules": settings.top_k_rules,
"embedding_model": settings.embedding_model,
}
print("Strat-Chatbot ready!")
logger.info("Strat-Chatbot ready")
yield
# ------------------------------------------------------------------
# Shutdown — release HTTP connection pools
# ------------------------------------------------------------------
logger.info("Shutting down Strat-Chatbot...")
print("Shutting down...")
if llm is not None:
await llm.close()
logger.debug("OpenRouterLLM HTTP client closed")
if gitea is not None:
await gitea.close()
logger.debug("GiteaIssueTracker HTTP client closed")
logger.info("Shutdown complete")
return lifespan
def create_app(settings: Settings | None = None) -> FastAPI:
"""Construct and return the production FastAPI application.
Args:
settings: Optional pre-built Settings instance. When *None* (the
common case), a new Settings() is constructed which reads from
environment variables and the .env file automatically.
Returns:
A fully-wired FastAPI application ready to be served by uvicorn.
"""
if settings is None:
settings = Settings()
app = FastAPI(
title="Strat-Chatbot",
description="Strat-O-Matic rules Q&A API",
version="0.1.0",
lifespan=_make_lifespan(settings),
)
app.include_router(router)
return app