mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-14 16:30:50 +00:00
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:
1 parent
b866b36ca9
commit
0132ecb342
6 files changed
+274
-24
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()
|
||||
}
|
||||
Reference in new issue
Block a user