mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Merge pull request #174 from IliesAllali/feat/idempotence-envois
Envois de la famille sans doublon (Idempotency-Key)
This commit is contained in:
6 files changed
+289
-1
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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}`,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user