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
+13 -4
View File
@@ -60,14 +60,17 @@ async function gatewayRequest(
route: string, route: string,
body?: unknown, body?: unknown,
signal?: AbortSignal, signal?: AbortSignal,
idempotencyKey?: string,
): Promise<{ ok: boolean; status: number; data: any }> { ): Promise<{ ok: boolean; status: number; data: any }> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-API-Key": cfg.apiKey,
};
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
const res = await fetch(`${cfg.url}${route}`, { const res = await fetch(`${cfg.url}${route}`, {
method, method,
signal, signal,
headers: { headers,
"Content-Type": "application/json",
"X-API-Key": cfg.apiKey,
},
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@@ -164,6 +167,8 @@ export default function qmemExtension(pi: ExtensionAPI) {
} }
const p = params as any; const p = params as any;
onUpdate?.({ content: [{ type: "text", text: "qmem: salvataggio record..." }] }); onUpdate?.({ content: [{ type: "text", text: "qmem: salvataggio record..." }] });
// Idempotency: stessa key per tutta l'operazione (e per eventuali retry)
const idemKey = crypto.randomUUID();
const { ok, status, data } = await gatewayRequest( const { ok, status, data } = await gatewayRequest(
cfg, cfg,
"POST", "POST",
@@ -180,6 +185,7 @@ export default function qmemExtension(pi: ExtensionAPI) {
supersede_reason: p.supersede_reason, supersede_reason: p.supersede_reason,
}, },
signal, signal,
idemKey,
); );
if (!ok) { if (!ok) {
return { return {
@@ -376,6 +382,8 @@ export default function qmemExtension(pi: ExtensionAPI) {
} }
onUpdate?.({ content: [{ type: "text", text: `qmem: supersede di ${memoryId}...` }] }); onUpdate?.({ content: [{ type: "text", text: `qmem: supersede di ${memoryId}...` }] });
// Idempotency: stessa key per tutta l'operazione (e per eventuali retry)
const idemKey = crypto.randomUUID();
const { ok, status, data } = await gatewayRequest( const { ok, status, data } = await gatewayRequest(
cfg, cfg,
"POST", "POST",
@@ -391,6 +399,7 @@ export default function qmemExtension(pi: ExtensionAPI) {
supersede_reason: p.reason, supersede_reason: p.reason,
}, },
signal, signal,
idemKey,
); );
if (!ok) { if (!ok) {
return { return {
+41 -2
View File
@@ -16,7 +16,10 @@ Endpoints:
GET /v1/status → health + statistiche GET /v1/status → health + statistiche
""" """
from __future__ import annotations
import asyncio import asyncio
import hashlib
import json import json
import logging import logging
import os import os
@@ -56,6 +59,24 @@ qdrant = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY)
# Rate limit in-memory: {key: [timestamps]} # Rate limit in-memory: {key: [timestamps]}
_ratelimit: dict[str, list[float]] = {} _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) # Cache overview metadati (TTL 60s, invalidata su scrittura)
_META_TTL_SECONDS = 60 _META_TTL_SECONDS = 60
_meta_cache: dict[str, Any] = {} _meta_cache: dict[str, Any] = {}
@@ -170,7 +191,22 @@ def startup() -> None:
# Endpoints # Endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@app.post("/v1/memories") @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()) memory_id = str(uuid.uuid4())
# Supersede: il nuovo record corregge uno esistente, che resta in archivio marcato # 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"]) _audit(key, "supersede", old_id=superseded_id, new_id=memory_id, kind=body.kind, agent_id=payload["agent_id"])
else: else:
_audit(key, "create", memory_id=memory_id, kind=body.kind, agent_id=payload["agent_id"]) _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") @app.post("/v1/memories:search")