mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-13 08:22:03 +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
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user