- 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
84 lines
2.7 KiB
Python
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()
|