mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-12 16:16:09 +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
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,36 +1,47 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { apiRequest } from './client.js'
|
||||
|
||||
import { ApiError, apiRequest } from './client'
|
||||
// Clé d'idempotence : gardée tant que l'envoi n'a pas abouti, libérée par une réussite
|
||||
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 { ok, status: ok ? 201 : 503, text: async () => '{}' }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
keysSent.length = 0
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('apiRequest', () => {
|
||||
it('une requête qui ne répond pas finit en erreur lisible', async () => {
|
||||
const timeout = vi.spyOn(AbortSignal, 'timeout')
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('trop long', 'TimeoutError')))
|
||||
await expect(apiRequest('/trees')).rejects.toMatchObject({
|
||||
name: 'ApiError', message: 'Le serveur ne répond pas. Vérifiez la connexion et réessayez.', status: 0, payload: { error: 'request_timeout' },
|
||||
})
|
||||
expect(timeout).toHaveBeenCalledWith(30_000)
|
||||
const send = (body = '{"a":1}') => apiRequest('/trees/t/annotation-photos', { method: 'POST', idempotent: true, body })
|
||||
|
||||
describe('apiRequest, envois idempotents', () => {
|
||||
it('un envoi refait après une coupure 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('un envoi de fichier a plus de temps, et un délai demandé passe devant', async () => {
|
||||
const timeout = vi.spyOn(AbortSignal, 'timeout')
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => new Response('{"ok":true}', { status: 200 })))
|
||||
await apiRequest('/upload', { method: 'POST', body: 'x'.repeat(200_000) })
|
||||
expect(timeout).toHaveBeenLastCalledWith(5 * 60_000)
|
||||
await apiRequest('/import', { method: 'POST', body: '{}', timeoutMs: 1234 })
|
||||
expect(timeout).toHaveBeenLastCalledWith(1234)
|
||||
expect(fetch.mock.calls[1][1]).not.toHaveProperty('timeoutMs')
|
||||
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('une autre panne réseau remonte telle quelle', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
const error = await apiRequest('/trees').catch((e) => e)
|
||||
expect(error).toBeInstanceOf(TypeError)
|
||||
expect(error).not.toBeInstanceOf(ApiError)
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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