feat: idempotency su POST /v1/memories (Idempotency-Key, replay → stessa risposta, payload diverso → 409)

- gateway: tabella in-memory con TTL 24h, hash canonico del payload, scoped per API key
- estensione: crypto.randomUUID() per operazione (store e correct), riusata su retry
- from __future__ import annotations (forward-reference _payload_hash)
This commit is contained in:
Matteo Benedetto
2026-08-16 19:10:32 +02:00
parent 9cb3008f4d
commit a7d4bbb4a8
2 changed files with 54 additions and 6 deletions
+41 -2
View File
@@ -16,7 +16,10 @@ Endpoints:
GET /v1/status → health + statistiche
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import os
@@ -56,6 +59,24 @@ qdrant = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY)
# Rate limit in-memory: {key: [timestamps]}
_ratelimit: dict[str, list[float]] = {}
# Idempotency in-memory: {api_key:key: {hash, response, ts}} (TTL 24h)
_IDEMPOTENCY_TTL_SECONDS = 24 * 3600
_idempotency: dict[str, dict[str, Any]] = {}
def _payload_hash(body: MemoryIn) -> str:
"""Hash canonico del payload per il confronto idempotenza."""
canonical = json.dumps(body.model_dump(), sort_keys=True, default=str)
return hashlib.sha256(canonical.encode()).hexdigest()
def _idempotency_cleanup() -> None:
"""Rimuove le entry idempotenza scadute (lazy, chiamato a ogni write)."""
now = time.time()
expired = [k for k, v in _idempotency.items() if now - v["ts"] > _IDEMPOTENCY_TTL_SECONDS]
for k in expired:
_idempotency.pop(k, None)
# Cache overview metadati (TTL 60s, invalidata su scrittura)
_META_TTL_SECONDS = 60
_meta_cache: dict[str, Any] = {}
@@ -170,7 +191,22 @@ def startup() -> None:
# Endpoints
# ---------------------------------------------------------------------------
@app.post("/v1/memories")
async def add_memory(body: MemoryIn, key: str = Depends(require_auth)) -> dict:
async def add_memory(
body: MemoryIn,
key: str = Depends(require_auth),
idempotency_key: Optional[str] = Header(default=None, alias="Idempotency-Key"),
) -> dict:
# Idempotency: replay della stessa richiesta (stessa key + stesso payload) → stessa risposta
idem_key = f"{key}:{idempotency_key}" if idempotency_key else None
if idem_key:
_idempotency_cleanup()
existing = _idempotency.get(idem_key)
if existing:
if existing["hash"] != _payload_hash(body):
raise HTTPException(status_code=409, detail="Idempotency-Key già usata con payload diverso")
_audit(key, "create_replay", idempotency_key=idempotency_key[:16])
return existing["response"]
memory_id = str(uuid.uuid4())
# Supersede: il nuovo record corregge uno esistente, che resta in archivio marcato
@@ -217,7 +253,10 @@ async def add_memory(body: MemoryIn, key: str = Depends(require_auth)) -> dict:
_audit(key, "supersede", old_id=superseded_id, new_id=memory_id, kind=body.kind, agent_id=payload["agent_id"])
else:
_audit(key, "create", memory_id=memory_id, kind=body.kind, agent_id=payload["agent_id"])
return {"memory_id": memory_id, "created_at": payload["created_at"], "supersedes_id": superseded_id}
response = {"memory_id": memory_id, "created_at": payload["created_at"], "supersedes_id": superseded_id}
if idem_key:
_idempotency[idem_key] = {"hash": _payload_hash(body), "response": response, "ts": time.time()}
return response
@app.post("/v1/memories:search")