Bliver dine POST-kald også dobbelt-kørt uden du opdager det?

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:

  1. Klienten genererer en unik nøgle for “den her operation”.
  2. Nøglen bliver sendt i en header, f.eks. Idempotency-Key: 79c....
  3. Serveren tjekker sin store (database/Redis) for nøglen.
  4. Hvis key findes, returnerer den det tidligere svar.
  5. 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:

  • key som PRIMARY KEY giver dig en naturlig unique constraint.
  • endpoint kan være en streng som POST /api/orders, hvis du vil genbruge keys på tværs af endpoints.
  • request_hash er f.eks. en SHA-256 hash af request-body’en. Det bruger du til at opdage, hvis nogen misbruger samme key til forskelligt indhold.
  • status lader dig skelne mellem in-progress og færdige kald.
  • expires_at styrer, 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 key som 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:

  1. Klient sender request med key og body.
  2. Server hasher body’en (f.eks. SHA-256 over JSON-strengen).
  3. Ved første request gemmer du hash i tabellen.
  4. Ved efterfølgende requests tjekker du: er hashen identisk?

Hvis hash’en er forskellig, har du et valg:

  • Returner 409 Conflict og 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:

  1. Webhook modtages med event.id = "evt_123".
  2. Du prøver at INSERT en række i din webhook_events-tabel med id = evt_123.
  3. Hvis det lykkes, kører du din logik (opret ordre, opdater status).
  4. 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:

  • key er 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.

Generer en stærk, unik nøgle på klienten, fx en UUID v4 eller en tilfældig 128-bit token, eller lad serveren udstede nøgler ved behov. På serveren validerer du format og længde, og scope nøglen til en given operation eller bruger (fx prefix med user-id) for at minimere kollisioner.
Brug en idempotency-record med statusfelter og en unik constraint, og skriv denne record i samme transaktion som den egentlige forretningshandling eller før operationen med opdatering til 'completed' bagefter. Hvis serveren crasher, kan en retry se enten 'in-progress' eller den færdige record og dermed undgå dobbeltarbejde, eller genafspille det gemte svar.
Opbevar keys mindst så længe som klientens forventede retry-window - typisk minutter til få dage; for betalinger eller revisionskrav kan 30-90 dage være nødvendigt. Implementer TTL-index eller et dagligt cleanup-job, og tag hensyn til compliance og supportbehov før sletning.
Ja - genreturner samme HTTP-status, body og relevante headers som ved første succesfulde udførsel, så klienten får et deterministisk svar. Tilføj eventuelt metadata som Idempotency-Key og en Idempotency-Status (fx completed eller in-progress) så klienten kan forstå hvad der skete.

Lasse Falkenberg er typen, der begyndte at rode med HTML og CSS for at lave en simpel bandside – og opdagede, at det var langt sjovere at få knapperne til at virke end at stå på scenen. Siden har han kastet sig over alt fra små JavaScript-snippets til Python-scripts, der kan spare ham for kedeligt, manuelt arbejde i hverdagen.

Han har lært det meste ved at bygge ting, der lige præcis løser hans egne problemer: en lille webapp til at holde styr på brætspilsaftener, et script til at rydde op i rodede mapper, eller en enkel side til at dele noter med venner. Undervejs har han kæmpet sig gennem alle de klassiske fejl – semikolon, forkerte indrykninger og variabler, der hedder noget helt andet end man tror – og det er præcis den rejse, han deler på Coding Class.

På Coding Class skriver Lasse praktiske, jordnære guides, der tager udgangspunkt i små, konkrete opgaver: noget du kan se, teste og bygge videre på med det samme. Han elsker at bryde en opgave ned i små bidder, vise den fulde kode og forklare linje for linje, hvad der sker – inklusive de typiske bugs, du med stor sandsynlighed også støder på.

For Lasse handler kodning ikke om flotte titler eller store ord, men om følelsen af at få noget til at virke – og om at du som læser kan gå derfra med noget, du selv har bygget. Hvis du kan kende glæden ved at få en fejl til endelig at forsvinde, er du lige på bølgelængde med hans måde at lære fra sig på.

Send kommentar

You May Have Missed