
Godkänn inte en skapande POST-rutt förrän du vet vad som händer när svaret försvinner efter skrivningen. Kräv en nyckel som är avgränsad per användare och operation, en jämförelse av innehållet och ett sparat svar som skrivs i samma transaktion som raden. Betalning och e-post täcks inte av det.
Svaret försvann, ordern fanns
En HTTP-metod är idempotent när den avsedda effekten av flera identiska anrop är densamma som av ett. MDN skriver att POST och PATCH inte är garanterat idempotenta, och visar POST /add_row som lägger till en ny rad vid varje anrop. Det är just den formen en skapande rutt brukar ha.
RFC 9110 förklarar varför egenskapen spelar roll: en idempotent begäran kan upprepas automatiskt om kommunikationen fallerar innan klienten hunnit läsa svaret. En klient bör inte göra så med en icke-idempotent metod, om den inte har något sätt att veta att begäran ändå är idempotent eller att originalet aldrig utfördes. Samma avsnitt nämner att vissa klienter gissar och kan prova POST igen när anslutningen stängts innan något av svaret kom. Ett nytt försök kan också komma från din egen omförsökslogik eller från en användare som klickar igen.
Servern ser då två korrekta anrop, och databasen gör vad den ska. Det som saknas är ett sätt att se att anropen är samma beställning.
Tre fel som ser likadana ut
| Fråga | Vad som går fel | Var det hör hemma |
|---|---|---|
| Blir alla skrivningar i ett anrop kvar, eller ingen? | Halva ordern sparas när steg tre kastar ett fel | Transaktion |
| Vad händer när två olika anrop ändrar samma värde? | Den sista skrivningen skriver över den första | Samtidighet |
| Är det här anropet samma beställning som ett tidigare? | Ett återförsök blir en ny order | Idempotens, den här artikeln |
En transaktion löser inte den tredje raden. Varianten nedan är atomär och skrev ändå två rader. Ett lås eller en atomär uppdatering löser den inte heller, eftersom två försök i följd inte ens överlappar.
Exemplet: samma rutt, olika skapa
Koden är konstruerad för artikeln och säger ingenting om hur ofta en viss modell skriver så. Den är ett granskningsexempel, ingen färdig produktionslösning: inloggningen är en testrubrik, kroppens storlek är inte begränsad och databasen är en lokal SQLite-fil. Den använder bara Node.js inbyggda moduler. Spara alla filer i samma mapp. Filen app.mjs är gemensam, och fältkontrollen följer samma tanke som tillåtelselistan mot mass assignment:
// app.mjs: samma rutter för alla varianter. Bara funktionen skapa byts ut.
import http from 'node:http';
import { DatabaseSync } from 'node:sqlite';
const OPERATIONER = { '/api/ordrar': 'order.skapa', '/api/returer': 'retur.skapa' };
const KATALOG = new Set(['testvara', 'provvara']);
export function oppnaDb(fil, vantaMs = 5000) {
const db = new DatabaseSync(fil);
db.exec(`PRAGMA busy_timeout = ${Number(vantaMs)};
CREATE TABLE IF NOT EXISTS poster (
id INTEGER PRIMARY KEY, anvandare TEXT NOT NULL, operation TEXT NOT NULL,
artikel TEXT NOT NULL, antal INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS idempotens (
anvandare TEXT NOT NULL, operation TEXT NOT NULL, nyckel TEXT NOT NULL,
avtryck TEXT NOT NULL, status INTEGER NOT NULL, svar TEXT NOT NULL,
PRIMARY KEY (anvandare, operation, nyckel));`);
return db;
}
export function skrivPost(db, anvandare, operation, indata) {
const { lastInsertRowid } = db.prepare(
'INSERT INTO poster (anvandare, operation, artikel, antal) VALUES (?, ?, ?, ?)',
).run(anvandare, operation, indata.artikel, indata.antal);
return { id: Number(lastInsertRowid), ...indata };
}
// Valideringen ger en fast fältordning, så att avtrycket inte beror på klientens JSON.
function tolkaIndata(kropp) {
if (kropp === null || typeof kropp !== 'object' || Array.isArray(kropp)) return null;
const { artikel, antal, ...ovrigt } = kropp;
if (Object.keys(ovrigt).length > 0 || !KATALOG.has(artikel)) return null;
if (!Number.isInteger(antal) || antal < 1 || antal > 10) return null;
return { artikel, antal };
}
async function lasKropp(req) {
let text = '';
for await (const bit of req) text += bit;
return JSON.parse(text);
}
export function skapaApp(db, skapa, krokar = {}) {
return http.createServer(async (req, res) => {
const svara = (status, kropp, aterspelat = false) => {
res.writeHead(status, {
'Content-Type': 'application/json',
...(aterspelat ? { 'X-Aterspelat': 'true' } : {}),
});
res.end(JSON.stringify(kropp));
};
try {
// Syntetisk inloggning för provet. Här ska din riktiga session in.
const anvandare = req.headers['x-testanvandare'];
if (!anvandare) return svara(401, { fel: 'Inte inloggad' });
const operation = OPERATIONER[new URL(req.url, 'http://localhost').pathname];
if (req.method !== 'POST' || !operation) return svara(404, { fel: 'Hittades inte' });
const nyckel = req.headers['idempotency-key'];
const indata = tolkaIndata(await lasKropp(req));
if (!indata) return svara(400, { fel: 'Ogiltig beställning' });
const svar = await skapa(db, { anvandare, operation, nyckel, indata }, krokar);
// Provets punkt mellan lyckad skrivning och levererat svar.
if (svar.status === 201 && !svar.aterspelat && krokar.efterCommit?.(res)) return;
return svara(svar.status, svar.kropp, svar.aterspelat);
} catch (err) {
if (err instanceof SyntaxError) return svara(400, { fel: 'Ogiltig JSON' });
// SQLITE_BUSY: låset släpptes inte i tid. Inget är sparat, så ett nytt försök är ofarligt.
if (err.errcode === 5) return svara(503, { fel: 'Försök igen' });
return svara(500, { fel: 'Kunde inte behandla anropet' });
}
});
}
Tabellen idempotens har en sammansatt primärnyckel. IETF-utkastet om rubriken Idempotency-Key beskriver en sådan sammansatt uppslagsnyckel som en möjlighet, där klientens nyckel kombineras med attribut som bara servern känner till. Avgränsningen till just användare och operation är vårt val. X-Aterspelat är en egen rubrik för provet.
Den första varianten är den som ser färdig ut. Skrivningen ligger i en transaktion och rullas tillbaka vid fel, men nyckeln läses aldrig. Spara den som before.mjs:
// Före: atomär skrivning, men servern minns inte tidigare försök.
import { skrivPost } from './app.mjs';
export async function skapa(db, begaran, krokar) {
const { anvandare, operation, indata } = begaran;
db.exec('BEGIN IMMEDIATE');
try {
const kropp = skrivPost(db, anvandare, operation, indata);
krokar.foreCommit?.();
db.exec('COMMIT');
return { status: 201, kropp };
} catch (err) {
if (db.isTransaction) db.exec('ROLLBACK');
throw err;
}
}
Den andra ser ut som lösningen. Den slår upp nyckeln, skriver raden och sparar sedan svaret. Spara den som kolla-forst.mjs. Raden med setImmediate står för den väntan en asynkron databasdrivrutin ger, eftersom node:sqlite saknar sådan väntan:
// Kolla först: letar efter nyckeln, men kontrollen och skrivningen är inte bundna.
import { createHash } from 'node:crypto';
import { skrivPost } from './app.mjs';
const avtryckAv = (indata) => createHash('sha256').update(JSON.stringify(indata)).digest('hex');
export async function skapa(db, begaran, krokar) {
const { anvandare, operation, nyckel, indata } = begaran;
if (!nyckel || nyckel.length > 255) return { status: 400, kropp: { fel: 'Ogiltig nyckel' } };
const avtryck = avtryckAv(indata);
const tidigare = db.prepare(
'SELECT avtryck, status, svar FROM idempotens WHERE anvandare = ? AND operation = ? AND nyckel = ?',
).get(anvandare, operation, nyckel);
if (tidigare) {
if (tidigare.avtryck !== avtryck) return { status: 422, kropp: { fel: 'Nyckeln gäller annat innehåll' } };
return { status: tidigare.status, kropp: JSON.parse(tidigare.svar), aterspelat: true };
}
await new Promise(setImmediate); // Står för väntan på en asynkron databasdrivrutin.
const kropp = skrivPost(db, anvandare, operation, indata);
krokar.foreCommit?.();
db.prepare(
'INSERT INTO idempotens (anvandare, operation, nyckel, avtryck, status, svar) VALUES (?, ?, ?, ?, ?, ?)',
).run(anvandare, operation, nyckel, avtryck, 201, JSON.stringify(kropp));
return { status: 201, kropp };
}
Den tredje binder ihop allt. Spara den som after.mjs:
// Efter: nyckelkontroll, skrivning och sparat svar ligger i samma transaktion.
import { createHash } from 'node:crypto';
import { skrivPost } from './app.mjs';
const avtryckAv = (indata) => createHash('sha256').update(JSON.stringify(indata)).digest('hex');
export async function skapa(db, begaran, krokar) {
const { anvandare, operation, nyckel, indata } = begaran;
if (!nyckel || nyckel.length > 255) return { status: 400, kropp: { fel: 'Ogiltig nyckel' } };
const avtryck = avtryckAv(indata);
// Ingen await mellan BEGIN och COMMIT: anslutningen delas inte med ett annat anrop.
db.exec('BEGIN IMMEDIATE');
try {
const tidigare = db.prepare(
'SELECT avtryck, status, svar FROM idempotens WHERE anvandare = ? AND operation = ? AND nyckel = ?',
).get(anvandare, operation, nyckel);
if (tidigare) {
db.exec('COMMIT');
if (tidigare.avtryck !== avtryck) return { status: 422, kropp: { fel: 'Nyckeln gäller annat innehåll' } };
return { status: tidigare.status, kropp: JSON.parse(tidigare.svar), aterspelat: true };
}
const kropp = skrivPost(db, anvandare, operation, indata);
krokar.foreCommit?.();
db.prepare(
'INSERT INTO idempotens (anvandare, operation, nyckel, avtryck, status, svar) VALUES (?, ?, ?, ?, ?, ?)',
).run(anvandare, operation, nyckel, avtryck, 201, JSON.stringify(kropp));
db.exec('COMMIT');
return { status: 201, kropp };
} catch (err) {
if (db.isTransaction) db.exec('ROLLBACK');
throw err;
}
}
Vad körningen visade
| Prov | Före | Kolla först | Efter |
|---|---|---|---|
| Två försök i följd, samma nyckel | 2 rader | 1 rad | 1 rad |
| Svaret försvinner efter skrivningen, nytt försök | 2 rader | 1 rad, samma id | 1 rad, samma id |
| Samma nyckel, ändrat antal | 201, ny rad | 422 | 422 |
| Fel före commit | Ingen rad kvar | Raden blir kvar efter 500 | Ingen rad kvar |
| 20 samtidiga försök, en process | 20 rader, 20 × 201 | 20 rader, 1 × 201 och 19 × 500 | 1 rad, 20 × 201 |
| 20 samtidiga försök, två processer | 20 rader, 20 × 201 | 2 rader, 19 × 201 och 1 × 500 | 1 rad, 20 × 201 |
Varje samtidighetsrad bygger på fem varv med samma utfall, men utfallet för kolla-forst.mjs beror på tidsläget och ska inte läsas som en fast siffra.
Mittenkolumnen är den lärorika. Den klarar de två första proven, så en diff och ett enkelt test ser bra ut. Med tjugo samtidiga försök i en process hann alla förbi uppslagningen. Primärnyckeln stoppade 19 av de sparade svaren, men då låg deras rader redan i poster. Klienterna fick 500 och raderna blev kvar. Ett unikt index skyddar bara tabellen det sitter på, så det hjälper inte om den verkliga skrivningen redan är gjord.
Nyckelns regler
En nyckel per logisk beställning. Klienten skapar nyckeln en gång och återanvänder den för alla försök med den beställningen. Provet ny nyckel gav två rader, så ett omförsöksskal som tar fram en ny nyckel vid varje försök ger ingen deduplicering alls. Utkastet rekommenderar UUID eller liknande slumpidentifierare, och Stripe föreslår V4 UUID och avråder från känsliga uppgifter i nyckeln.
Avgränsa per användare och operation. Samma nyckel från en annan användare, eller mot /api/returer, skapade en egen rad i provet. Med bara nyckeln som primärnyckel skulle en användare kunna få tillbaka en annan användares sparade svar. Det är vår slutsats av modellen och provades inte som angrepp.
Jämför innehållet. Samma nyckel med annat antal gav 422 utan skrivning. Utkastet anger att resursen bör svara 422 vid sådan återanvändning och 400 när en nyckel som krävs saknas. Stripe skriver att dess lager jämför parametrarna med originalet och ger fel om de skiljer sig. Avtrycket i exemplet räknas på den validerade indatan med fast fältordning, så {"antal":2,"artikel":"testvara"} räknas som samma innehåll som omvänd ordning. Ett avtryck på den råa kroppen skulle behandla dem som olika. Utkastet har status aktivt Internet-Draft, version 08 från 22 oktober 2026, och är ingen RFC. Statuskoderna är alltså ett val du bör dokumentera.
Validera före nyckeln. En ogiltig beställning gav 400 utan sparat svar, så klienten kunde rätta anropet och använda samma nyckel. Stripe API v1 gör likadant vid fallerad validering.
Samtidiga försök och fel
BEGIN IMMEDIATE startar enligt SQLite en skrivning direkt, och kan ge SQLITE_BUSY om en annan anslutning redan har en aktiv skrivtransaktion. Med busy_timeout väntar anslutningen i stället en stund, här som mest 5 sekunder. Det andra försöket kommer in först när det första har sparat sitt svar och kan läsa det. Därför gav provet med två processer tjugo gånger 201 och samma id.
Inom en process gäller något annat. DatabaseSync är en anslutning där alla anrop körs synkront, och eftersom after.mjs inte har någon await mellan BEGIN och COMMIT hinner inget annat anrop in. Har din kod en asynkron drivrutin behöver varje transaktion en egen anslutning, annars hamnar andra anrops satser i din transaktion. Det är vår slutsats och inget vi körde.
- Låset släpps inte i tid. Vi höll skrivlåset från en annan anslutning och gav rutten 50 millisekunder. Svaret blev 503, inget sparades och nästa försök fick 201 och en rad.
- Fel före commit. Rutten svarade 500, både rad och nyckel rullades tillbaka och nästa försök skapade ordern. Stripe API v1 gör annorlunda: där får upprepningar det sparade svaret även vid 500, när endpointen väl börjat köras. Klienten ska ändå behandla ett 500 som osäkert: i provet med ett fel efter commit svarade rutten 500 trots att raden var sparad.
- Väntan i stället för 409. Utkastet anger att resursen bör svara 409 på ett försök medan originalet pågår. Vi avviker, eftersom transaktionen är kort och det inte finns något pågående tillstånd att visa. Om arbetet tar lång tid håller väntan låset för länge. Då behövs enligt vår bedömning ett sparat pågår-tillstånd och 409, vilket inte ingår i provet.
Stripe är en annan modell
Stripe är en förlaga men ingen specifikation för din databas. Sidans regler gäller Stripes produkt och skiljer sig mellan API-versioner. För API v1 sparar Stripe statuskod och kropp efter att endpointen börjat köras, och nycklar kan tas bort automatiskt när de är minst 24 timmar gamla. Används nyckeln igen efter det blir det en ny begäran. För API v2 skriver Stripe att återförsök inte upprepar lyckade operationer och att misslyckat eller delvis misslyckat arbete slutförs när det går. Stripe anger att en begäran med nyckel kan upprepas vid anslutningsfel utan risk för ett andra objekt, men det påståendet gäller Stripes API. Vi har inte gjort några anrop dit.
Vår modell gallrar ingenting, och det håller inte i längden. Provet gallrad nyckel visar vad gallringen kostar: raden i idempotens togs bort, samma nyckel kom igen och en andra order skapades. Enligt vår bedömning ska nycklarna alltså sparas längre än det längsta fönstret där en klient kan prova igen, kö och manuella omtag inräknade. Utkastet säger att resursen bör definiera och dokumentera en sådan utgångspolicy om nycklar tidsbegränsas.
E-post och betalning ligger utanför
Allt ovan gäller det som skrivs i samma transaktion. Två prov visar gränsen, med en räknare som står för ett utskick:
- Effekten före commit. Räknaren gick upp, ett fel kastades före commit och försöket upprepades. Resultat: en rad men två utskick.
- Effekten efter commit. Ett fel kastades efter commit men före utskicket, som om processen fallit där. Återförsöket fick det sparade svaret och nådde aldrig utskicket. Resultat: en rad och inget utskick.
Ingen ordning ger alltså exakt en gång med den här koden. Det du behöver är en egen lösning för effekten, till exempel en mottagare som själv deduplicerar på en nyckel eller en utkorgsrad som skrivs i transaktionen och levereras med omförsök. Det är vår riktning och inte körd här, och även den ger bara åtminstone en leverans om inte mottagaren deduplicerar.
Testerna
Testet startar riktiga HTTP-servrar, använder en tillfällig databasfil per test och läser raderna i databasen efter varje prov. Spara serverfilen som server.mjs:
// server.mjs: egen process mot samma databasfil. Används av testet med två processer.
import { oppnaDb, skapaApp } from './app.mjs';
const { skapa } = await import(process.argv[3]);
const app = skapaApp(oppnaDb(process.argv[2]), skapa);
app.listen(0, '127.0.0.1', () => console.log(app.address().port));
Lägg testfilen bredvid och kör node --test idempotens.test.mjs. Byt strängen i raden VARIANT till ./before.mjs eller ./kolla-forst.mjs för att se de andra varianterna falla:
// Kör med: node --test idempotens.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { oppnaDb, skapaApp } from './app.mjs';
const VARIANT = './after.mjs';
const { skapa } = await import(VARIANT);
const BESTALLNING = { artikel: 'testvara', antal: 2 };
const anropare = (bas) => (nyckel, kropp = BESTALLNING, { vag = '/api/ordrar', anvandare = 'u1' } = {}) =>
fetch(bas + vag, {
method: 'POST',
headers: {
'x-testanvandare': anvandare,
'Content-Type': 'application/json',
...(nyckel ? { 'Idempotency-Key': nyckel } : {}),
},
body: typeof kropp === 'string' ? kropp : JSON.stringify(kropp),
});
async function starta(t, krokar = {}, vantaMs = 5000) {
const mapp = mkdtempSync(join(tmpdir(), 'idempotens-'));
const fil = join(mapp, 'prov.db');
const db = oppnaDb(fil, vantaMs);
const app = skapaApp(db, skapa, krokar);
await new Promise((klar) => app.listen(0, '127.0.0.1', klar));
const barn = [];
t.after(async () => {
// Barnprocesserna håller databasfilen öppen och måste vara borta före rensningen.
await Promise.all(barn.map((b) => new Promise((klar) => { b.once('exit', klar); b.kill(); })));
app.closeAllConnections();
await new Promise((klar) => app.close(klar));
db.close();
rmSync(mapp, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
});
return { db, fil, barn, anrop: anropare(`http://127.0.0.1:${app.address().port}`) };
}
const raknaPoster = (db) => db.prepare('SELECT COUNT(*) AS n FROM poster').get().n;
const raknaNycklar = (db) => db.prepare('SELECT COUNT(*) AS n FROM idempotens').get().n;
test('samma nyckel och samma innehåll ger samma svar och en rad', async (t) => {
const { db, anrop } = await starta(t);
const forsta = await anrop('k1');
const andra = await anrop('k1');
assert.equal(forsta.status, 201);
assert.equal(andra.status, 201);
assert.equal(andra.headers.get('x-aterspelat'), 'true');
assert.deepEqual(await andra.json(), await forsta.json());
assert.equal(raknaPoster(db), 1);
});
test('annan fältordning är samma innehåll', async (t) => {
const { db, anrop } = await starta(t);
await anrop('k1', '{"artikel":"testvara","antal":2}');
const andra = await anrop('k1', '{"antal":2,"artikel":"testvara"}');
assert.equal(andra.status, 201);
assert.equal(raknaPoster(db), 1);
});
test('samma nyckel och ändrat innehåll ger konflikt utan skrivning', async (t) => {
const { db, anrop } = await starta(t);
await anrop('k1');
const andra = await anrop('k1', { artikel: 'testvara', antal: 3 });
assert.equal(andra.status, 422);
assert.equal(raknaPoster(db), 1);
assert.equal(db.prepare('SELECT antal FROM poster').get().antal, 2);
});
test('ny nyckel är en ny beställning', async (t) => {
const { db, anrop } = await starta(t);
await anrop('k1');
const andra = await anrop('k2');
assert.equal(andra.status, 201);
assert.equal(raknaPoster(db), 2);
});
test('nyckeln gäller per användare', async (t) => {
const { db, anrop } = await starta(t);
const forsta = await (await anrop('k1')).json();
const svar = await anrop('k1', BESTALLNING, { anvandare: 'u2' });
assert.equal(svar.status, 201);
assert.equal(svar.headers.get('x-aterspelat'), null);
assert.notEqual((await svar.json()).id, forsta.id);
assert.equal(raknaPoster(db), 2);
});
test('nyckeln gäller per operation', async (t) => {
const { db, anrop } = await starta(t);
await anrop('k1');
const svar = await anrop('k1', BESTALLNING, { vag: '/api/returer' });
assert.equal(svar.status, 201);
assert.equal(svar.headers.get('x-aterspelat'), null);
assert.equal(raknaPoster(db), 2);
});
test('saknad nyckel nekas', async (t) => {
const { db, anrop } = await starta(t);
assert.equal((await anrop(null)).status, 400);
assert.equal(raknaPoster(db), 0);
});
test('ogiltig beställning förbrukar inte nyckeln', async (t) => {
const { db, anrop } = await starta(t);
assert.equal((await anrop('k1', { artikel: 'testvara', antal: -1 })).status, 400);
assert.equal(raknaNycklar(db), 0);
assert.equal((await anrop('k1')).status, 201);
assert.equal(raknaPoster(db), 1);
});
test('svaret försvinner efter skrivningen och återförsöket får samma order', async (t) => {
let tappat = false;
const { db, anrop } = await starta(t, {
efterCommit(res) {
if (tappat) return false;
tappat = true;
res.destroy();
return true;
},
});
await assert.rejects(anrop('k1'));
assert.equal(raknaPoster(db), 1);
const omtag = await anrop('k1');
assert.equal(omtag.status, 201);
assert.equal((await omtag.json()).id, 1);
assert.equal(raknaPoster(db), 1);
});
test('fel före commit lämnar varken rad eller sparat svar', async (t) => {
let felKvar = 1;
const { db, anrop } = await starta(t, {
foreCommit() {
if (felKvar-- > 0) throw new Error('Provfel');
},
});
assert.equal((await anrop('k1')).status, 500);
assert.equal(raknaPoster(db), 0);
assert.equal(raknaNycklar(db), 0);
assert.equal((await anrop('k1')).status, 201);
assert.equal(raknaPoster(db), 1);
});
test('tjugo samtidiga försök i samma process ger en rad', async (t) => {
const { db, anrop } = await starta(t);
const svar = await Promise.all(Array.from({ length: 20 }, () => anrop('k1')));
assert.deepEqual([...new Set(svar.map((s) => s.status))], [201]);
const kroppar = await Promise.all(svar.map((s) => s.json()));
assert.equal(new Set(kroppar.map((k) => k.id)).size, 1);
assert.equal(raknaPoster(db), 1);
});
function startaProcess(fil, barn) {
const serverprocess = spawn(process.execPath, ['--no-warnings', 'server.mjs', fil, VARIANT], {
cwd: import.meta.dirname, stdio: ['ignore', 'pipe', 'inherit'],
});
barn.push(serverprocess);
return new Promise((klar) => {
serverprocess.stdout.once('data', (port) => klar(anropare(`http://127.0.0.1:${String(port).trim()}`)));
});
}
test('tjugo samtidiga försök via två processer ger en rad', async (t) => {
const { db, fil, barn } = await starta(t);
const a = await startaProcess(fil, barn);
const b = await startaProcess(fil, barn);
const svar = await Promise.all(Array.from({ length: 20 }, (_, i) => (i % 2 ? a : b)('k1')));
assert.deepEqual([...new Set(svar.map((s) => s.status))], [201]);
const kroppar = await Promise.all(svar.map((s) => s.json()));
assert.equal(new Set(kroppar.map((k) => k.id)).size, 1);
assert.equal(raknaPoster(db), 1);
});
test('upptagen databas ger 503 och återförsöket lyckas', async (t) => {
const { db, fil, anrop } = await starta(t, {}, 50);
const annan = new DatabaseSync(fil);
annan.exec('BEGIN IMMEDIATE');
assert.equal((await anrop('k1')).status, 503);
annan.exec('ROLLBACK');
annan.close();
assert.equal((await anrop('k1')).status, 201);
assert.equal(raknaPoster(db), 1);
});
test('begränsning: gallrad nyckel skapar en ny rad', async (t) => {
const { db, anrop } = await starta(t);
await anrop('k1');
db.exec('DELETE FROM idempotens');
assert.equal((await anrop('k1')).status, 201);
assert.equal(raknaPoster(db), 2);
});
test('begränsning: extern effekt före commit kan köras två gånger', async (t) => {
let skickat = 0;
const { db, anrop } = await starta(t, {
foreCommit() {
skickat += 1;
if (skickat === 1) throw new Error('Commit hann inte');
},
});
assert.equal((await anrop('k1')).status, 500);
assert.equal((await anrop('k1')).status, 201);
assert.equal(raknaPoster(db), 1);
assert.equal(skickat, 2);
});
test('begränsning: extern effekt efter commit kan utebli', async (t) => {
let skickat = 0;
let forst = true;
const { db, anrop } = await starta(t, {
efterCommit() {
if (forst) {
forst = false;
throw new Error('Processen dog före utskicket');
}
skickat += 1;
return false;
},
});
assert.equal((await anrop('k1')).status, 500);
const omtag = await anrop('k1');
assert.equal(omtag.status, 201);
assert.equal(omtag.headers.get('x-aterspelat'), 'true');
assert.equal(raknaPoster(db), 1);
assert.equal(skickat, 0);
});
Testerna med prefixet begränsning slår fast det koden inte gör. De blir röda om någon senare ändrar just det beteendet, och då vet du att beskrivningen också behöver ändras.
Faktiskt utfört prov: den 7 november 2026 kördes koden med Node.js v24.13.0 på Windows 11, med syntetiska beställningar och servrar som bara lyssnade på 127.0.0.1. Med after.mjs gick 16 av 16 tester igenom, i tio körningar i följd. Med kolla-forst.mjs gick 12 igenom och 4 föll. Med before.mjs gick 8 igenom och 8 föll. Radantalen och statuskoderna i tabellen kommer från en separat mätslinga över samma moduler, som inte är publicerad. Godkända prov visar att just dessa försök gav rätt utfall, inte att alla tidslägen är uteslutna.
Node 24.13.0 skrev en ExperimentalWarning för SQLite, utan någon flagga. Dokumentationen för v24 anger att flaggan försvann i v22.13.0 och att modulen är Release candidate från v24.15.0. Nyare dokumentation kallar klassen Database och DatabaseSync ett föråldrat alias, så namnet kan behöva bytas i en senare Node-version.
Så granskar du en AI-skriven POST-rutt
- Vad händer om svaret försvinner efter skrivningen och samma anrop kommer igen? Kör det i ett test och räkna raderna.
- Vilka fält ingår i nyckelns omfång? Saknas användare eller operation kan nycklar krocka.
- Ligger uppslagningen, skrivningen och det sparade svaret i samma transaktion? Finns en
awaitmellan dem på en delad anslutning? - Jämförs innehållet, och i vilken form? Fältordning ska inte avgöra.
- Vad händer vid fel före commit, och vad betyder ett 500 för klienten?
- Hur länge sparas nycklarna, och är det längre än klienternas omförsök?
- Vilka effekter lämnar databasen, och vad händer med dem vid ett återförsök?
Du kan ställa samma frågor till kodassistenten, men svaret är en utgångspunkt. Testerna avgör.
Det provet inte visar
- Andra databaser. Postgres, MySQL och asynkrona anslutningspooler provades inte. Isolering och låsning kan skilja sig, så kör testerna mot din egen stack.
- Ett riktigt nätverk. Det tappade svaret skapades genom att servern stängde sin socket. Ingen proxy eller lastbalanserare fanns i provet.
- Slutgiltiga affärsnej. Exemplet sparar bara 201. Om ett nej, till exempel tomt lager, ska spelas upp igen eller prövas på nytt är ett beslut som koden inte tar.
- Last. Tjugo samtidiga försök i fem varv är ett prov av kontrollflödet, inget lasttest.
FAQ
Vad betyder idempotens?
En HTTP-metod är idempotent när den avsedda effekten av flera identiska anrop är densamma som av ett. POST är inte garanterat idempotent, så en rutt som skapar beställningar behöver en egen mekanism för återförsök.
Räcker en transaktion för att stoppa dubbla beställningar?
Nej. I vårt prov var varje anrop atomärt, men två försök gav ändå två rader. Transaktionen hindrar delresultat inom ett anrop. Den vet inte att nästa anrop är samma beställning.
Vad ska hända om samma nyckel kommer med annat innehåll?
Neka anropet utan att skriva. Vårt exempel svarar 422, som IETF-utkastet om Idempotency-Key anger. Utkastet är ingen RFC, så statuskoden är ett val du bör dokumentera.
Ger en idempotensnyckel exakt en gång för e-post och betalning?
Nej, inte av sig själv. I vårt prov kördes en extern effekt två gånger när den låg före commit och uteblev när den låg efter commit. Nyckeln skyddar det som skrivs i samma transaktion.
Källor och metod
Källorna kontrollerades 7 november 2026: MDN om idempotens för definitionen och POST; RFC 9110 för idempotenta metoder och automatiska återförsök; Stripes API-referens för Stripes regler per API-version; IETF:s datatracker för utkastets status och utkastets version 08 för statuskoder, avtryck och nyckelregler; SQLite om transaktioner och om busy_timeout för låsbeteendet; Node.js dokumentation för v24 och den aktuella för node:sqlite. Koden och provet är AI-kreatörs egna, och koden i artikeln är den som kördes. Stripes API har inte anropats. Artikeln gör inget påstående om hur ofta AI-verktyg skriver sådan här kod.