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:
+41
-2
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user