"""Stato runtime condiviso tra bootstrap e route.""" from __future__ import annotations import contextvars import hashlib import json import time from typing import Any import httpx from qdrant_client import QdrantClient from config import QDRANT_API_KEY, QDRANT_URL, QDRANT_RETRIES, log, _metrics class ResilientQdrant: """Proxy del client Qdrant che ritenta i metodi su errori di transport (connessione/timeout transienti, es. riavvio del container Qdrant). Gli errori applicativi (404, validazione) non vengono ritentati.""" def __init__(self, client: Any, attempts: int = QDRANT_RETRIES, backoff_s: float = 0.4): self._client = client self._attempts = max(1, attempts) self._backoff = backoff_s @staticmethod def _transient(exc: Exception) -> bool: if isinstance(exc, httpx.TransportError): return True return type(exc).__name__ in ("ConnectionError", "TimeoutError") def __getattr__(self, name: str) -> Any: attr = getattr(self._client, name) if not callable(attr): return attr def wrapped(*args: Any, **kwargs: Any) -> Any: for attempt in range(self._attempts): try: return attr(*args, **kwargs) except Exception as exc: # noqa: BLE001 if attempt == self._attempts - 1 or not self._transient(exc): raise delay = self._backoff * (2**attempt) _metrics["qdrant_retries"] += 1 log.warning( "qdrant.%s: errore transiente (%s: %s) → retry %d/%d tra %.1fs", name, exc.__class__.__name__, exc, attempt + 2, self._attempts, delay, ) time.sleep(delay) return wrapped qdrant = ResilientQdrant(QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY)) request_id: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-") ratelimit: dict[str, list[float]] = {} status_ratelimit: dict[str, list[float]] = {} idempotency: dict[str, dict[str, Any]] = {} meta_cache: dict[str, Any] = {} STATUS_RATE_LIMIT_PER_MIN = 30 IDEMPOTENCY_TTL_SECONDS = 24 * 3600 def payload_hash(body: Any) -> str: canonical = json.dumps(body.model_dump(), sort_keys=True, default=str) return hashlib.sha256(canonical.encode()).hexdigest() def idempotency_cleanup() -> None: now = time.time() expired = [k for k, v in idempotency.items() if now - v["ts"] > IDEMPOTENCY_TTL_SECONDS] for key in expired: idempotency.pop(key, None) def invalidate_meta() -> None: meta_cache.clear()