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