Envois de la famille sans doublon : en-tête Idempotency-Key

Sur un réseau faible, une proposition, un souvenir, une photo posée sur l'arbre ou un moment pouvait être
créé deux fois : la réponse se perdait (délai dépassé), la personne renvoyait, le serveur recréait tout.

- Navigateur (api/client.js) : un envoi qui n'a pas abouti garde sa clé, et le même envoi refait (même
  adresse, même contenu) la réutilise. Une réussite libère la clé.
- Serveur (lib/idempotency.ts) : sur les quatre routes de création, une clé déjà vue par la même personne
  renvoie la réponse du premier envoi ; deux essais simultanés n'en traitent qu'un ; un échec n'est pas gardé.
  En mémoire, 24 h, 5 000 clés au plus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-27 15:14:32 +01:00
1 parent b866b36ca9
commit 0132ecb342
6 files changed
+274 -24

No files matched your search

+33 -1
View File
@@ -38,13 +38,44 @@ function timeoutSignal(options) {
return AbortSignal.timeout(options.timeoutMs ?? (isLargeBody ? UPLOAD_TIMEOUT_MS : DEFAULT_TIMEOUT_MS))
}
// Envois sans doublon (backend/src/lib/idempotency.ts). Un envoi qui n'a pas abouti (délai dépassé, réseau coupé,
// erreur) garde sa clé, et le même envoi refait (même adresse, même contenu) la réutilise : si le premier avait en
// fait abouti, le serveur renvoie sa réponse au lieu de tout recréer. Une réussite libère la clé, et renvoyer le même
// contenu plus tard (un souvenir supprimé puis remis) est un nouvel envoi.
const unsettledKeys = new Map()
// Empreinte rapide du contenu (cyrb53) : crypto.subtle n'existe pas hors HTTPS (auto-hébergement en réseau local)
function fingerprint(text) {
let h1 = 0xdeadbeef
let h2 = 0x41c6ce57
for (let i = 0; i < text.length; i += 1) {
const ch = text.charCodeAt(i)
h1 = Math.imul(h1 ^ ch, 2654435761)
h2 = Math.imul(h2 ^ ch, 1597334677)
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
return `${text.length}:${(h2 >>> 0).toString(36)}${(h1 >>> 0).toString(36)}`
}
// getRandomValues existe aussi hors HTTPS, contrairement à crypto.randomUUID
function newIdempotencyKey() {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
}
async function apiRequest(path, options = {}) {
const { timeoutMs: _timeoutMs, ...fetchOptions } = options
const { timeoutMs: _timeoutMs, idempotent = false, ...fetchOptions } = options
const providedHeaders = options.headers || {}
const hasExplicitContentType = Object.keys(providedHeaders).some((key) => key.toLowerCase() === 'content-type')
const print = idempotent ? `${path}|${fingerprint(typeof options.body === 'string' ? options.body : '')}` : null
if (print && !unsettledKeys.has(print)) unsettledKeys.set(print, newIdempotencyKey())
const headers = {
...(options.body !== undefined && !hasExplicitContentType ? { 'Content-Type': 'application/json' } : {}),
...(print ? { 'Idempotency-Key': unsettledKeys.get(print) } : {}),
...providedHeaders,
}
@@ -68,6 +99,7 @@ async function apiRequest(path, options = {}) {
if (!response.ok) {
throw new ApiError(payload?.error || `request_failed_${response.status}`, response.status, payload)
}
if (print) unsettledKeys.delete(print)
return payload
}