Merge pull request #174 from IliesAllali/feat/idempotence-envois

Envois de la famille sans doublon (Idempotency-Key)
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-27 15:43:38 +01:00
commit d5d446bc32
6 files changed
+289 -1

No files matched your search

+4
View File
@@ -9,6 +9,7 @@ import { Prisma } from './generated/prisma/client.js'
import { env } from './config/env.js'
import { prisma } from './lib/prisma.js'
import { isUserTokenRevoked, loadRevokedTokens } from './lib/token-revocation.js'
import { registerIdempotency } from './lib/idempotency.js'
import { authRoutes } from './routes/auth.js'
import { contributionRoutes } from './routes/contributions.js'
import { graphRoutes } from './routes/graph.js'
@@ -156,6 +157,9 @@ export function createApp() {
app.get('/health', async () => ({ ok: true }))
// Après l'identification : une clé d'idempotence ne vaut que pour la personne qui l'a envoyée
registerIdempotency(app)
app.register(authRoutes, { prefix: '/auth' })
app.register(oidcRoutes, { prefix: '/auth/oidc' })
app.register(publicRoutes)
+112
View File
@@ -0,0 +1,112 @@
import { createHash } from 'node:crypto'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
// Envois rejoués sans doublon (en-tête Idempotency-Key). Sur un réseau faible, la famille renvoie une proposition
// ou une photo dont la réponse s'est perdue : le serveur avait déjà tout créé, et l'arbre recevait deux fois la
// même chose. Le navigateur tire une clé au début d'un envoi et la garde pour chaque nouvel essai ; le serveur
// répond au second essai avec la réponse du premier, sans rien recréer. Deux essais simultanés : le second attend
// le premier.
//
// En mémoire, sur une seule instance de l'API (le cas de Parenthèse et de l'auto-hébergement) : un redémarrage
// oublie les clés, ce qui ne recrée de doublon que si un essai tombe pile pendant le redémarrage.
// Les créations faites par la famille : propositions, souvenirs, photos posées sur l'arbre, moments
const IDEMPOTENT_ROUTES = new Set([
'POST /trees/:id/contributions/sessions',
'POST /trees/:id/persons/:personId/media',
'POST /trees/:id/annotation-photos',
'POST /trees/:id/persons/:personId/life/events',
])
const TTL_MS = 24 * 60 * 60 * 1000
const MAX_ENTRIES = 5000
const KEY_PATTERN = /^[A-Za-z0-9_-]{16,100}$/
type Stored = { statusCode: number, contentType: string | undefined, payload: string }
type Entry = { expires: number, done: Stored | null, settled: Promise<void>, settle: () => void }
const entries = new Map<string, Entry>()
function prune(now: number) {
for (const [scope, entry] of entries) {
if (entry.expires <= now) entries.delete(scope)
}
// Au-delà du plafond, les plus anciennes partent d'abord (la Map garde l'ordre d'insertion)
while (entries.size > MAX_ENTRIES) {
const oldest = entries.keys().next().value
if (oldest === undefined) break
entries.get(oldest)?.settle()
entries.delete(oldest)
}
}
// Une clé ne vaut que pour la même route, la même adresse et la même personne : celle d'un autre ne rejoue rien
function scopeOf(request: FastifyRequest, key: string) {
const who = createHash('sha256').update(request.headers.authorization ?? `ip:${request.ip}`).digest('hex').slice(0, 32)
return `${request.method} ${request.url} ${who} ${key}`
}
function replay(reply: FastifyReply, stored: Stored) {
if (stored.contentType) reply.header('content-type', stored.contentType)
return reply.code(stored.statusCode).header('idempotent-replayed', 'true').send(stored.payload)
}
export function registerIdempotency(app: FastifyInstance) {
app.addHook('preHandler', async (request, reply) => {
if (!IDEMPOTENT_ROUTES.has(`${request.method} ${request.routeOptions.url}`)) return
const key = request.headers['idempotency-key']
if (typeof key !== 'string' || !KEY_PATTERN.test(key)) return
const now = Date.now()
prune(now)
const scope = scopeOf(request, key)
// Un essai précédent est en cours : on attend sa fin, puis on rejoue sa réponse s'il a abouti
for (let existing = entries.get(scope); existing; existing = entries.get(scope)) {
if (existing.done) return replay(reply, existing.done)
await existing.settled
}
let settle = () => {}
const settled = new Promise<void>((resolve) => { settle = resolve })
entries.set(scope, { expires: now + TTL_MS, done: null, settled, settle })
request.idempotencyScope = scope
})
app.addHook('onSend', async (request, reply, payload) => {
const scope = request.idempotencyScope
if (!scope) return payload
const entry = entries.get(scope)
if (!entry) return payload
// Seule une réussite se rejoue ; un échec laisse le prochain essai refaire la requête
if (reply.statusCode < 300 && typeof payload === 'string') {
const contentType = reply.getHeader('content-type')
entry.done = { statusCode: reply.statusCode, contentType: typeof contentType === 'string' ? contentType : undefined, payload }
} else {
entries.delete(scope)
}
entry.settle()
return payload
})
// Requête coupée avant toute réponse : l'essai suivant ne doit pas attendre pour rien
app.addHook('onResponse', async (request) => {
const scope = request.idempotencyScope
const entry = scope ? entries.get(scope) : undefined
if (entry && !entry.done) {
entries.delete(scope as string)
entry.settle()
}
})
}
declare module 'fastify' {
interface FastifyRequest {
idempotencyScope?: string
}
}
// Tests
export function resetIdempotency() {
entries.clear()
}
+87
View File
@@ -0,0 +1,87 @@
import Fastify, { type FastifyInstance } from 'fastify'
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { registerIdempotency, resetIdempotency } from '../src/lib/idempotency.js'
// Un envoi rejoué avec la même clé (réponse perdue sur un réseau faible) ne crée rien une seconde fois
let app: FastifyInstance
let created = 0
let failNext = false
const KEY = 'a1b2c3d4-e5f6-4789-9abc-def012345678'
const send = (headers: Record<string, string> = {}, url = '/trees/t1/contributions/sessions') =>
app.inject({ method: 'POST', url, headers: { authorization: 'Bearer famille', ...headers }, payload: { title: 'x' } })
beforeAll(async () => {
app = Fastify()
registerIdempotency(app)
app.post('/trees/:id/contributions/sessions', async (_request, reply) => {
await new Promise((resolve) => setTimeout(resolve, 30))
if (failNext) {
failNext = false
return reply.code(503).send({ error: 'busy' })
}
created += 1
return reply.code(201).send({ session: { id: `s${created}` } })
})
app.post('/trees/:id/autre', async () => {
created += 1
return { ok: created }
})
await app.ready()
})
afterAll(async () => {
await app.close()
})
beforeEach(() => {
created = 0
failNext = false
resetIdempotency()
})
describe('Idempotency-Key', () => {
it('rejoue la première réponse sans recréer', async () => {
const first = await send({ 'idempotency-key': KEY })
const second = await send({ 'idempotency-key': KEY })
expect(created).toBe(1)
expect(second.statusCode).toBe(201)
expect(second.json()).toEqual(first.json())
expect(second.headers['idempotent-replayed']).toBe('true')
})
it('deux essais simultanés : un seul est traité', async () => {
const [a, b] = await Promise.all([send({ 'idempotency-key': KEY }), send({ 'idempotency-key': KEY })])
expect(created).toBe(1)
expect(a.json()).toEqual(b.json())
})
it('un échec n’est pas gardé : l’essai suivant refait la requête', async () => {
failNext = true
const first = await send({ 'idempotency-key': KEY })
const second = await send({ 'idempotency-key': KEY })
expect(first.statusCode).toBe(503)
expect(second.statusCode).toBe(201)
expect(created).toBe(1)
})
it('la clé d’une autre personne ne rejoue rien', async () => {
await send({ 'idempotency-key': KEY })
await send({ 'idempotency-key': KEY, authorization: 'Bearer quelquun-dautre' })
expect(created).toBe(2)
})
it('sans clé, ou avec une clé mal formée, chaque envoi compte', async () => {
await send()
await send()
await send({ 'idempotency-key': 'court' })
await send({ 'idempotency-key': 'court' })
expect(created).toBe(4)
})
it('les autres routes ne sont pas concernées', async () => {
await send({ 'idempotency-key': KEY }, '/trees/t1/autre')
await send({ 'idempotency-key': KEY }, '/trees/t1/autre')
expect(created).toBe(2)
})
})
+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
}
+49
View File
@@ -34,3 +34,52 @@ describe('apiRequest', () => {
expect(error).not.toBeInstanceOf(ApiError)
})
})
// Clé d'idempotence : gardée tant que l'envoi n'a pas abouti, libérée par une réussite
describe('apiRequest, envois idempotents', () => {
const keysSent = []
const respond = (ok) => vi.fn(async (_url, init) => {
keysSent.push(init.headers['Idempotency-Key'])
if (ok === 'network') throw new TypeError('Failed to fetch')
return new Response('{}', { status: ok ? 201 : 503 })
})
const send = (body = '{"a":1}') => apiRequest('/trees/t/annotation-photos', { method: 'POST', idempotent: true, body })
afterEach(() => {
keysSent.length = 0
})
it('un envoi refait après une coupure ou une erreur garde la même clé', async () => {
vi.stubGlobal('fetch', respond('network'))
await expect(send()).rejects.toThrow()
vi.stubGlobal('fetch', respond(false))
await expect(send()).rejects.toThrow()
vi.stubGlobal('fetch', respond(true))
await send()
expect(keysSent).toHaveLength(3)
expect(new Set(keysSent).size).toBe(1)
expect(keysSent[0]).toMatch(/^[0-9a-f]{32}$/)
})
it('après une réussite, le même contenu repart avec une clé neuve', async () => {
vi.stubGlobal('fetch', respond(true))
await send('{"b":2}')
await send('{"b":2}')
expect(keysSent[0]).not.toBe(keysSent[1])
})
it('un autre contenu a sa propre clé, et un appel ordinaire n’en envoie pas', async () => {
vi.stubGlobal('fetch', respond('network'))
await expect(send('{"c":3}')).rejects.toThrow()
await expect(send('{"c":4}')).rejects.toThrow()
await expect(apiRequest('/trees/t', { method: 'POST', body: '{}' })).rejects.toThrow()
expect(keysSent[0]).not.toBe(keysSent[1])
expect(keysSent[2]).toBeUndefined()
})
it('l’option idempotent ne part pas dans fetch', async () => {
vi.stubGlobal('fetch', respond(true))
await send('{"d":5}')
expect(fetch.mock.calls[0][1]).not.toHaveProperty('idempotent')
})
})
+4
View File
@@ -71,6 +71,7 @@ export async function fetchPersonLife(treeId, personId, token) {
export async function createLifeEvent(treeId, personId, payload, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/life/events`, {
method: 'POST',
idempotent: true,
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify(payload),
})
@@ -373,6 +374,7 @@ export async function listTreeVisits(treeId, token) {
export async function submitContributionSession(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/contributions/sessions`, {
method: 'POST',
idempotent: true,
headers: {
Authorization: `Bearer ${token}`,
},
@@ -421,6 +423,7 @@ export async function fetchContributionStatus(treeId, sessionId, token) {
export async function uploadAnnotationPhoto(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/annotation-photos`, {
method: 'POST',
idempotent: true,
headers: {
Authorization: `Bearer ${token}`,
},
@@ -433,6 +436,7 @@ export async function uploadAnnotationPhoto(treeId, payload, token) {
export async function uploadPersonMedia(treeId, personId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/media`, {
method: 'POST',
idempotent: true,
headers: {
Authorization: `Bearer ${token}`,
},