Files
pi-qmem/gateway/state.py
T
enne2 fcd6b1670e feat(gateway): catena di fallback per gli embedding + retry transiente su Qdrant
- embed.py: EMBED_CHAIN (JSON per-nodo {name,url,api,key,timeout_ms}, api
  llamacpp|ollama), cooldown 60s sui nodi falliti, validazione dimensione
  EMBED_DIM, compatibilità legacy quando la catena è vuota
- state.py: ResilientQdrant — proxy che ritenta i metodi del client Qdrant
  su httpx.TransportError (store/search/transienti), errori applicativi
  esenti; contatore qdrant_retries in metriche
- metrics: qmem_embed_calls_total + durata per backend
- /v1/version espone embed_nodes; versione 2.10.0
- test: 11 nuovi (chain, cooldown, dim mismatch, legacy, retry transiente) — 58 pass
2026-09-08 12:19:16 +02:00

84 lines
2.7 KiB
Python

"""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()