Bliver dine POST-kald også dobbelt-kørt uden du opdager det?
Dubletter er sjældent brugerens skyld
Hvis din bruger klikker “Betal” to gange, og du trækker pengene to gange, er det ikke brugerens fejl. Det er din.
Det samme gælder ordrer, signup, filuploads, booking af tider. Alt det, hvor samme request ikke må oprette flere rækker i databasen.
Det ubehagelige er, at du ofte først opdager problemet sent: når support får klager, regnskabet ikke stemmer, eller en kollega tilfældigt ser dobbelte ordrer i et admin-panel.
Idempotency keys er det kedelige, men ret effektive værn mod den slags. Og ja, Stripe bruger det til betalinger, men du kan bruge den samme idé på helt almindelige endpoints.
Før vs efter idempotency: hvad er forskellen egentlig?
Jeg vil starte med en lille før/efter sammenligning, for det er her pointen lander klarest.
| Uden idempotency | Med idempotency key |
|---|---|
| Bruger klikker “Betal” to gange → to POST /charges rammer backend. | Bruger klikker “Betal” to gange → begge requests har samme Idempotency-Key. |
Backend opretter to rækker i payments og reserverer to beløb. |
Backend ser key i sin store, finder første result, genbruger samme response. |
| Dubletter skal håndteres manuelt i support eller vha. scripts. | Systemet garanterer: én unik operation per key. |
| Retry fra klient (pga. timeout) kan lave endnu en dublet. | Retry fra klient er forventet adfærd, ikke en trussel. |
| Fejl er svære at reproducere, fordi de afhænger af timing. | Adfærden er deterministisk: samme key, samme resultat. |
Så hele pointen er: samme operation + samme key = samme effekt. Også selv om serveren får requestet to, tre eller ti gange.
Hvad betyder idempotent i API-verdenen?
Begrebet kommer fra matematik, men HTTP-verdenen har stjålet det og gjort det lidt mere jordnært.
GET og PUT er allerede idempotente (i teorien)
Når man siger, at et HTTP-verb er idempotent, betyder det: du kan kalde det samme endpoint med samme data igen og igen, uden at resultatet ændrer sig efter første gang.
- GET henter data. 10 x GET på samme URL burde ikke ændre noget som helst.
- PUT erstatter en ressource. 10 x PUT med samme body burde ende i samme state som første gang.
- DELETE er også defineret som idempotent. Du sletter den samme ressource flere gange, men efter første gang er den væk.
Det er selvfølgelig idealet. I praksis har jeg set GET /users der skriver til logtabeller, sender metrikker og endda opretter historik. Ikke anbefalet.
POST er typisk ikke idempotent
POST bliver ofte brugt til “opret noget nyt”. Og her er problemet:
- To ens POST-requests => to nye ordrer, to nye brugere, to nye betalinger.
- Netværket kan finde på at retry’e for dig.
- Browseren kan sende igen, hvis brugeren refresher en form-submission.
Det er her idempotency keys kommer ind: de gør et specifikt POST-kald idempotent, uden at du skal til at misbruge HTTP-metoderne.
Mønstret: Idempotency-Key header + server-side store
Grundideen er simpel:
- Klienten genererer en unik nøgle for “den her operation”.
- Nøglen bliver sendt i en header, f.eks.
Idempotency-Key: 79c.... - Serveren tjekker sin store (database/Redis) for nøglen.
- Hvis key findes, returnerer den det tidligere svar.
- Hvis ikke, udfører den operationen, gemmer resultatet, og returnerer det.
Et simpelt request kan se sådan her ud:
POST /api/orders
Idempotency-Key: 123e4567-e89b-12d3-a456-426614174000
Content-Type: application/json
{
"cartId": "c_42",
"paymentMethodId": "pm_abc123"
}
Hvis klienten laver retry med samme body og samme key, vil du enten:
- få samme 201-response med samme
orderId, eller - få en fejl, hvis der er en konflikt.
Og det er nu, det bliver lidt mere konkret. For hvor gemmer du det? Hvad med TTL? Hvad hvis body”en ændrer sig?
Datamodel: sådan kan din idempotency store se ud
Du har groft sagt to klassiske valg: en tabel i databasen eller noget i stil med Redis. Lad os starte med SQL-varianten.
SQL-tabel med unique constraint
En simpel model i f.eks. Postgres kunne se sådan ud:
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
endpoint TEXT NOT NULL,
request_hash TEXT NOT NULL,
status TEXT NOT NULL, -- 'in_progress', 'succeeded', 'failed'
response_body JSONB,
response_code INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ
);
Nogle detaljer, der gør en forskel:
keysom PRIMARY KEY giver dig en naturlig unique constraint.endpointkan være en streng somPOST /api/orders, hvis du vil genbruge keys på tværs af endpoints.request_hasher f.eks. en SHA-256 hash af request-body’en. Det bruger du til at opdage, hvis nogen misbruger samme key til forskelligt indhold.statuslader dig skelne mellem in-progress og færdige kald.expires_atstyrer, hvor længe du gider gemme det.
Så kan du lave noget i den her stil i din kode (pseudo):
// 1. Slå key op
row = SELECT * FROM idempotency_keys WHERE key = $key;
if (row && row.status == 'succeeded') {
return cached_response(row);
}
if (row && row.status == 'in_progress') {
// Enten vent eller returner 409/202 afhængigt af dit API-design
}
// 2. Opret key som in_progress inden du laver noget farligt
INSERT INTO idempotency_keys (key, endpoint, request_hash, status)
VALUES ($key, $endpoint, $hash, 'in_progress');
// 3. Kør din forretningslogik i en transaktion
BEGIN;
order = create_order(...);
COMMIT;
// 4. Gem resultatet
UPDATE idempotency_keys
SET status = 'succeeded',
response_body = $json,
response_code = 201
WHERE key = $key;
return response(201, json);
Redis-variant med TTL
Hvis du har Redis (eller noget lignende) i forvejen, kan du gøre det lidt lettere vægtigt:
- Key:
idempotency:{id} - Value: JSON med status, response_code, body, request_hash
- TTL: f.eks. 24 timer
Eksempel i pseudo-JS:
const cacheKey = `idempotency:${idempotencyKey}`;
const entry = await redis.get(cacheKey);
if (entry && entry.status === 'succeeded') {
return send(entry.response_code, entry.body);
}
if (!entry) {
await redis.set(cacheKey, { status: 'in_progress', request_hash: hash }, 'EX', 86400);
}
// Kør logik, opret ordre osv.
await redis.set(cacheKey, {
status: 'succeeded',
response_code: 201,
body: responseJson,
request_hash: hash
}, 'EX', 86400);
Bemærk at du mister noget, hvis du kun bruger Redis: data ryger ud, når TTL udløber eller hvis din cache tømmes. Nogle systemer har det fint med det. Til betalinger vil jeg personligt have det i en database.
Før vs efter: hvad med race conditions?
Indtil nu har vi snydt lidt. Vi har antaget, at der kun kommer én request ad gangen. Det gør der ikke altid.
Forestil dig to næsten samtidige requests med samme idempotency key:
- Request A checker: ingen række i tabellen.
- Request B checker: stadig ingen række.
- Begge laver INSERT.
Hvis du er uheldig, får du to rækker eller to ordrer. Det var netop det, vi prøvede at undgå.
Brug databasen som lås
En enkel strategi er at gøre selve INSERT-operationen til din lås:
- Definér
keysom PRIMARY KEY eller UNIQUE. - Prøv at
INSERT. Hvis den fejler med unique violation, ved du, at en anden request er “først”.
Eksempel i pseudo-code for Postgres:
try {
INSERT INTO idempotency_keys (key, endpoint, request_hash, status)
VALUES ($key, $endpoint, $hash, 'in_progress');
} catch (UniqueViolationError) {
// En anden request har allerede oprettet nøglen
row = SELECT * FROM idempotency_keys WHERE key = $key;
if (row.status == 'succeeded') return cached_response(row);
// Ellers: vent, eller svar med 409 / 202
}
Her bruger du databasen (som alligevel håndterer concurrency for dig) i stedet for at opfinde egne låse.
Transaktioner omkring den farlige del
Selve “create order”-delen bør også være i en transaktion. Især hvis du både:
- opretter rækker i flere tabeller, og
- skriver til idempotency-tabel i samme flow.
En klassisk sekvens:
BEGIN;
// 1. Forsøg at insert'e idempotency key (unik lås)
INSERT ...;
// 2. Opret ordre/betaling
INSERT INTO orders ...;
// 3. Opdater idempotency row til succeeded
UPDATE idempotency_keys SET status = 'succeeded', ...;
COMMIT;
Hvis noget fejler midtvejs, ruller du hele transaktionen tilbage. Så hænger din idempotency-tabel ikke og peger på en halvfærdig ordre.
Hvad gør du, hvis body’en ændrer sig?
En vigtig detalje: du vil normalt ikke acceptere, at samme idempotency key bruges til forskellige bodies.
Det er her request_hash kommer i spil.
Flowet kan være:
- Klient sender request med key og body.
- Server hasher body’en (f.eks. SHA-256 over JSON-strengen).
- Ved første request gemmer du hash i tabellen.
- Ved efterfølgende requests tjekker du: er hashen identisk?
Hvis hash’en er forskellig, har du et valg:
- Returner
409 Conflictog sig: “Idempotency-Key allerede brugt til anden payload”. - Log det hårdt, for det er typisk en klient-bug.
MDN beskriver 409 Conflict som netop den slags “requesten kan ikke gennemføres pga. konflikt med current state”. Det passer meget godt her.
Klientsiden: hvornår genererer du idempotency keys?
Alt det ovenstående virker kun, hvis klienten spiller med.
En simpel tommelfingerregel:
- Generér en ny key, når brugeren starter en ny operation (ny ordre, ny betaling, ny form).
- Genbrug samme key på retries af samme operation.
Konkrete scenarier
Nogle eksempler jeg selv har brugt:
- Betaling: lav key, når brugeren trykker “Betal”. Gem den i lokal state. Hvis du får timeout, eller UI’et vælger at retry’e, så brug samme key, indtil du er sikker på, at operationen er afsluttet.
- Signup: lav key, når brugeren sender signup-formen. Hvis de refresher og submitter igen med samme email, kan du håndtere det pænt.
- Filupload: lav key per fil, så du undgår at oprette samme upload-record to gange, hvis browseren sender igen.
På web-klienter kan du ofte bare bruge crypto.randomUUID() til nøglerne. På mobilapps giver det mening at have en lille idempotency-service, der holder styr på aktive keys per skærm/flow.
Webhooks: samme problem, bare uden bruger
Webhook-afsendere (Stripe, Shopify, GitHub osv.) laver ofte automatiske retries, hvis din endpoint svarer langsomt eller fejler.
Det betyder, at du kan få det samme event igen og igen. Igen: uden idempotency ender du med dublette ordrer, dobbelte mails, osv.
Brug event-id som idempotency key
Langt de fleste webhook-systemer sender et unikt ID for hvert event. Det kan du bruge direkte.
Flowet ligner meget det, vi havde før:
- Webhook modtages med
event.id = "evt_123". - Du prøver at
INSERTen række i dinwebhook_events-tabel medid = evt_123. - Hvis det lykkes, kører du din logik (opret ordre, opdater status).
- Hvis du får unique violation, ved du, at du har set eventet før. Så kan du vælge bare at svare 200 igen.
Det her mønster kan du kombinere med det generelle idempotency-key-mønster, men ofte er en simpel events-tabel nok:
CREATE TABLE webhook_events (
id TEXT PRIMARY KEY,
payload JSONB NOT NULL,
processed_at TIMESTAMPTZ
);
Til kritiske flows (betalinger) kan du lade dig inspirere af Stripes detaljerede forklaring: Stripe om idempotency.
Tjekliste: fra skrøbeligt POST-endpoint til idempotent på 30 minutter
Når jeg selv reviewer backend-kode, har jeg efterhånden en fast lille mental tjekliste for nye POST-endpoints.
1. Er det her endpoint kandidat til dublet-problemer?
Spørg dig selv:
- Opretter det nye rækker med sideeffekter (penge, mails, bookinger)?
- Er det realistisk, at klienten kan finde på at retry’e?
- Ville en dublet være svær at rydde op efter manuelt?
Hvis du har to ja’er, er det typisk værd at gøre idempotent.
2. Har du et sted at gemme idempotency keys?
Vælg en af disse:
- En dedikeret SQL-tabel med
PRIMARY KEY (key). - En Redis-store med TTL og et lille JSON-objekt.
Hvis du alligevel arbejder med database-migrationer, er det et fint lille ekstra migration-script.
3. Genererer klienten en Idempotency-Key?
Sørg for:
- at key kommer fra klienten, ikke serveren,
- at den sendes i en fast header, f.eks.
Idempotency-Key, - at den genbruges på retries af samme operation.
4. Har du hash-check på request-body?
Implementér:
- hash body’en deterministisk (samme body, samme hash),
- gem hash ved første call,
- returner 409, hvis samme key bliver brugt med anden hash.
5. Beskytter du dig mod race conditions?
Tjek at:
keyer underlagt en unik constraint,- du håndterer unique-violations som “en anden request var først”,
- du har transaktion omkring “opret ressource” + “opdater idempotency”.
6. Har du besluttet TTL og oprydning?
Idempotency-data lever ikke nødvendigvis for evigt. Overvej:
- For betalinger: behold i lang tid (måneder).
- For mindre kritiske ting: måske kun dage.
- Lav en simpel oprydnings-job, der sletter udløbne keys.
7. Ved teamet, hvordan det skal bruges?
Slut af med lidt dokumentation:
- Beskriv headeren og forventet adfærd i dit API-doc.
- Tilføj tjekpunkter i jeres code review-template, ligesom man gør med secrets eller async-fejl.
En lille indrømmelse til sidst
Jeg implementerede først ordentlig idempotency, da en hobbyapp lavede dobbelt-oprettelse af bookinger for en ven. Vi sad og kiggede på databasen, mens han sagde: “Jeg klikkede altså kun én gang”.
Det havde han ikke. Browseren havde været langsom, så han havde klikket to gange. Men problemet var stadig mit.
Næste aften sad jeg med kaffe, mørkt tema i editoren og lavede min første lille idempotency_keys-tabel. Siden har jeg haft betydeligt færre “mystiske” dublet-bugs. Og når der så alligevel dukker en op, ved jeg, at det ikke er min POST-endpoint der snubler. Det er rart at kunne krydse den slags af på listen.
Næste gang du bygger et nyt POST-endpoint med sideeffekter, så tænk “før vs efter” et øjeblik. Det er sjovere at bruge 30 minutter på en idempotency key nu end en søndag på at rydde op i dobbelt-betalinger senere.









Send kommentar
Du skal være logget ind for at skrive en kommentar.