Idempotente endpoints in Go: sleutels, deduplicatietabellen en herpogingen
Ontwerp idempotente endpoints in Go met idempotentiesleutels, deduplicatietabellen en handlers die veilig opnieuw kunnen worden uitgevoerd voor betalingen, imports en webhooks.

Waarom herpogingen duplicaten veroorzaken (en waarom idempotentie belangrijk is)
Herproberen gebeurt zelfs als er niets écht mis is. Een client ervaart een timeout terwijl de server nog bezig is. Een mobiele verbinding valt weg en de app probeert opnieuw. Een job runner krijgt een 502 en stuurt automatisch hetzelfde verzoek nogmaals. Met at-least-once levering (veel voorkomend bij queues en webhooks) zijn duplicaten normaal.
Daarom is idempotentie belangrijk: herhaalde verzoeken moeten tot hetzelfde eindresultaat leiden als één enkel verzoek.
Een paar termen zijn makkelijk door elkaar te halen:
- Safe: aanroepen verandert geen staat (zoals een read).
- Idempotent: meerdere aanroepen hebben hetzelfde effect als één aanroep.
- At-least-once: de afzender probeert opnieuw totdat het "blijft hangen", dus de ontvanger moet met duplicaten omgaan.
Zonder idempotentie kunnen herpogingen echt schade aanrichten. Een betaalendpoint kan twee keer afschrijven als de eerste betaling geslaagd is maar de response de client niet bereikt. Een import-endpoint kan dubbele rijen maken wanneer een worker na een timeout herproeft. Een webhook-handler kan hetzelfde event twee keer verwerken en twee e-mails versturen.
Belangrijk punt: idempotentie is een API-contract, geen interne implementatiedetail. Clients moeten weten wat ze kunnen herproberen, welke sleutel ze moeten sturen en welke response ze kunnen verwachten wanneer een duplicaat wordt gedetecteerd. Als je gedrag stilletjes verandert, breek je retry-logica en creëer je nieuwe faalmodi.
Idempotentie vervangt ook geen monitoring en reconciliatie. Houd duplicaatpercentages bij, log "replay"-beslissingen en vergelijk periodiek externe systemen (zoals een payment provider) met je database.
Kies de idempotentie-scope en regels per endpoint
Voordat je tabellen of middleware toevoegt, bepaal wat "hetzelfde verzoek" betekent en wat je server belooft te doen als een client herprobeert.
De meeste problemen ontstaan bij POST omdat dat vaak iets aanmaakt of een bijwerking triggert (kaart afschrijven, bericht verzenden, import starten). PATCH kan ook idempotentie nodig hebben als het side effects triggert, niet alleen een eenvoudige veldupdate. GET mag geen staat veranderen.
Definieer de scope: waar een sleutel uniek is
Kies een scope die bij je businessregels past. Te breed blokkeert geldig werk. Te smal laat duplicaten toe.
Veelvoorkomende scopes:
- Per endpoint + klant
- Per endpoint + extern object (bijvoorbeeld invoice_id of order_id)
- Per endpoint + tenant (voor multi-tenant systemen)
- Per endpoint + betaalmethode + bedrag (alleen als je productregels dat toelaten)
Voorbeeld: voor een "Create payment" endpoint maak je de sleutel uniek per klant. Voor "Ingest webhook event" scope je het naar de provider event ID (globale uniciteit van de provider).
Beslis wat je herhaalt bij duplicaten
Wanneer een duplicaat binnenkomt, geef je hetzelfde resultaat terug als de eerste succesvolle poging. In de praktijk betekent dat het herafspelen van dezelfde HTTP-statuscode en dezelfde response body (of in ieder geval hetzelfde resource-ID en dezelfde status).
Clients vertrouwen hierop. Als de eerste poging slaagde maar het netwerk viel weg, mag de retry niet een tweede afschrijving of een tweede importjob veroorzaken.
Kies een retentievenster
Sleutels moeten vervallen. Bewaar ze lang genoeg om realistische herpogingen en vertraagde jobs te dekken.
- Betalingen: 24 tot 72 uur is gebruikelijk.
- Imports: een week kan redelijk zijn als gebruikers later opnieuw kunnen proberen.
- Webhooks: sluit aan op het retrybeleid van de provider.
Definieer "hetzelfde verzoek": expliciete sleutel vs body-hash
Een expliciete idempotentiesleutel (header of veld) is meestal de schoonste regel.
Een body-hash kan als backstop helpen, maar breekt makkelijk bij onschuldige wijzigingen (veldvolgorde, whitespace, timestamps). Als je hashing gebruikt, normaliseer de input en wees strikt over welke velden je meeneemt.
Idempotentiesleutels: hoe ze in de praktijk werken
Een idempotentiesleutel is een simpel contract tussen client en server: "Als je deze sleutel opnieuw ziet, behandel het als hetzelfde verzoek." Het is één van de meest praktische tools voor retry-veilige API's.
De sleutel kan van beide kanten komen, maar voor de meeste API's moet de client hem genereren. De client weet wanneer het dezelfde actie opnieuw probeert en kan dezelfde sleutel over pogingen hergebruiken. Server-gegenereerde sleutels helpen wanneer je eerst een "draft" resource maakt (zoals een importjob) en daarna clients laat herhalen door naar dat job-ID te verwijzen, maar ze helpen niet bij het allereerste verzoek.
Gebruik een willekeurige, niet-raadbare string. Streef naar minstens 128 bits randomness (bijvoorbeeld 32 hex chars of een UUID). Bouw geen sleutels uit timestamps of user IDs.
Op de server sla je de sleutel op met genoeg context om misbruik te detecteren en het oorspronkelijke resultaat te replayen:
- Wie de call deed (account- of user-ID)
- Op welk endpoint of operatie het van toepassing is
- Een hash van de belangrijke requestvelden
- Huidige status (in-progress, succeeded, failed)
- De te replayen response (statuscode en body)
Een sleutel moet gescope zijn, typisch per gebruiker (of per API-token) plus endpoint. Als dezelfde sleutel wordt hergebruikt met een andere payload, weiger die dan met een duidelijke fout. Dat voorkomt onbedoelde botsingen waarbij een buggy client een nieuw betalingsbedrag stuurt met een oude sleutel.
Bij replay geef je hetzelfde resultaat terug als de eerste succesvolle poging. Dat betekent dezelfde HTTP-statuscode en dezelfde response body, niet een verse read die mogelijk veranderd is.
Dedup-tabellen in PostgreSQL: een eenvoudig, betrouwbaar patroon
Een speciale deduplicatietabel is een van de eenvoudigste manieren om idempotentie te implementeren. Het eerste verzoek maakt een rij voor de idempotentiesleutel. Elke retry leest diezelfde rij en geeft het opgeslagen resultaat terug.
Wat je moet opslaan
Houd de tabel klein en gefocust. Een veelgebruikte structuur:
key: de idempotentiesleutel (text)owner: wie de sleutel bezit (user_id, account_id of API client ID)request_hash: een hash van de belangrijke requestveldenresponse: de uiteindelijke response payload (vaak JSON) of een verwijzing naar een opgeslagen resultaatcreated_at: wanneer de sleutel voor het eerst gezien werd
De unieke constraint is de kern van het patroon. Dwing uniciteit af op (owner, key) zodat één client geen duplicaten kan aanmaken en twee verschillende clients niet in botsing komen.
Sla ook een request_hash op zodat je sleutelmisbruik kunt detecteren. Als een retry binnenkomt met dezelfde sleutel maar een andere hash, geef dan een fout in plaats van twee verschillende operaties te mixen.
Retentie en indexering
Dedup-rijen moeten niet voor altijd blijven bestaan. Bewaar ze lang genoeg om echte retry-vensters te dekken en ruim ze daarna op.
Voor snelheid bij hoge belasting:
- Unieke index op
(owner, key)voor snelle insert of lookup - Optionele index op
created_atom cleanup goedkoop te maken
Als de response groot is, sla dan een pointer op (bijvoorbeeld een result ID) en bewaar de volledige payload elders. Dat vermindert tabelgroei terwijl retry-gedrag consistent blijft.
Stapsgewijs: een retry-veilige handler flow in Go
Een retry-veilige handler heeft twee dingen nodig: een stabiele manier om "hetzelfde verzoek opnieuw" te identificeren, en een duurzame plek om de eerste uitkomst op te slaan zodat je die kunt replayen.
Een praktische flow voor betalingen, imports en webhook-ingestie:
-
Valideer het verzoek, en leid dan drie waarden af: een idempotentiesleutel (uit een header of client-veld), een owner (tenant of user ID) en een request-hash (hash van de belangrijke velden).
-
Start een database-transactie en probeer een dedup-record aan te maken. Maak het uniek op
(owner, key). Slarequest_hash, status (started, completed) en placeholders voor de response op. -
Als de insert conflict oplevert, laad dan de bestaande rij. Als die
completedis, geef de opgeslagen response terug. Als diestartedis, wacht kort (eenvoudige polling) of geef 409/202 zodat de client later opnieuw probeert. -
Alleen wanneer je succesvol de dedup-rij "in bezit" hebt, voer je de businesslogica één keer uit. Schrijf bijwerkingen binnen dezelfde transactie wanneer mogelijk. Persist de business-uitkomst plus de HTTP-response (statuscode en body).
-
Commit, en log met de idempotentiesleutel en owner zodat support duplicaten kan traceren.
Een minimaal tabelpatroon:
create table idempotency_keys (
owner_id text not null,
idem_key text not null,
request_hash text not null,
status text not null,
response_code int,
response_body jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (owner_id, idem_key)
);
Voorbeeld: een "Create payout" endpoint time-out na het afschrijven. De client probeert opnieuw met dezelfde sleutel. Je handler loopt tegen de conflict aan, ziet een completed record en geeft het oorspronkelijke payout-ID terug zonder opnieuw af te schrijven.
Betalingen: charge precies één keer, zelfs bij timeouts
Betalingen zijn waar idempotentie essentieel wordt. Netwerken falen, mobiele apps proberen opnieuw en gateways timen soms out nadat ze al een charge hebben gemaakt.
Een praktische regel: de idempotentiesleutel beschermt het aanmaken van de charge, en de payment provider ID (charge/intent ID) wordt daarna de bron van waarheid. Zodra je een provider-ID opslaat, maak je voor hetzelfde verzoek geen nieuwe charge.
Een patroon dat herpogingen en gateway-onzekerheid afhandelt:
- Lees en valideer de idempotentiesleutel.
- Maak in een database-transactie een payment-rij aan of haal die op, keyed by
(merchant_id, idempotency_key). Als er al eenprovider_idstaat, geef het opgeslagen resultaat terug. - Als er geen
provider_idis, roep je de gateway aan om een PaymentIntent/Charge te maken. - Als de gateway slaagt, persist
provider_iden markeer de betaling als “succeeded” (of “requires_action”). - Als de gateway time-out of een onbekend resultaat teruggeeft, sla status “pending” op en geef een consistente response terug die de client vertelt dat het veilig is om opnieuw te proberen.
De sleutel is hoe je met timeouts omgaat: veronderstel geen mislukking. Markeer de betaling als pending en bevestig later bij de gateway (of via een webhook) met behulp van de provider ID zodra je die hebt.
Foutresponses moeten voorspelbaar zijn. Clients bouwen retry-logica op wat je teruggeeft, dus houd statuscodes en foutvormen stabiel.
Imports en batch-endpoints: dedup zonder voortgang te verliezen
Imports zijn waar duplicaten het meeste pijn doen. Een gebruiker uploadt een CSV, je server time-out na 95% en zij drukken op retry. Zonder plan maak je ofwel dubbele rijen of dwing je ze opnieuw te beginnen.
Bij batchwerk denk je in twee lagen: de importjob en de items daarin. Job-niveau idempotentie voorkomt dat hetzelfde verzoek meerdere jobs aanmaakt. Item-niveau idempotentie voorkomt dat dezelfde rij twee keer wordt toegepast.
Een job-niveau patroon is om een idempotentiesleutel per import-request te eisen (of er een af te leiden van een stabiele request-hash plus user ID). Sla die op bij een import_job-record en geef bij retries hetzelfde job-ID terug. De handler moet kunnen zeggen: "Ik heb deze job gezien, dit is zijn huidige staat," in plaats van "begin opnieuw."
Voor item-niveau dedup vertrouw je op een natuurlijke sleutel die al in de data bestaat. Bijvoorbeeld: elke rij bevat een external_id uit het bron-systeem, of een stabiele combinatie zoals (account_id, email). Dwing het af met een unieke constraint in PostgreSQL en gebruik upsert-gedrag zodat herpogingen geen duplicaten creëren.
Bepaal van tevoren wat een replay doet wanneer een rij al bestaat. Maak het expliciet: sla over, update specifieke velden of faal. Vermijd "merge" tenzij je zeer duidelijke regels hebt.
Gedeeltelijk succes is normaal. In plaats van één grote "ok" of "failed" te retourneren, sla per-rij resultaten op gekoppeld aan de job: rijnummer, natuurlijke sleutel, status (created, updated, skipped, error) en een foutmelding. Bij een retry kun je veilig opnieuw draaien terwijl je dezelfde resultaten behoudt voor rijen die al afgerond zijn.
Om imports herstartbaar te maken, voeg checkpoints toe. Verwerk in pagina's (bijvoorbeeld 500 rijen per keer), sla de laatst verwerkte cursor op (rij-index of source-cursor) en werk die bij na elke commit van een pagina. Als het proces crasht, hervat de volgende poging vanaf de laatste checkpoint.
Webhook-ingestie: dedup, valideer en verwerk veilig
Webhook-senders proberen opnieuw. Ze sturen ook events soms buiten volgorde. Als je handler bij elke levering state bijwerkt, maak je uiteindelijk dubbele records, verstuur je dubbele e-mails of charge je dubbel.
Begin met het kiezen van de beste dedup-sleutel. Als de provider een uniek event-ID geeft, gebruik dat. Gebruik alleen een hash van de payload als fallback wanneer er geen event-ID is.
Security eerst: verifieer de signature voordat je iets accepteert. Als de signature faalt, wijs het verzoek af en schrijf geen dedup-record. Anders kan een aanvaller een event ID "reserveren" en echte events later blokkeren.
Een veilige flow onder herpogingen:
- Verifieer signature en basisvorm (verplichte headers, event ID).
- Insert het event ID in een dedup-tabel met een unieke constraint.
- Als de insert faalt door duplicaat, geef meteen 200 terug.
- Sla de ruwe payload (en headers) op wanneer dat nuttig is voor audit en debugging.
- Enqueue verwerking en geef snel 200 terug.
Snel ack'en is belangrijk omdat veel providers korte timeouts hebben. Doe het kleinste betrouwbare werk in het request: verifieer, dedup, persist. Verwerk daarna asynchroon (worker, queue, background job). Als je niet async kunt, houd verwerking idempotent door interne bijwerkingen te keyen op datzelfde event ID.
Out-of-order levering is normaal. Ga er niet vanuit dat "created" vóór "updated" arriveert. Geef de voorkeur aan upserts per externe object-ID en track de laatst verwerkte event-timestamp of versie.
Raw payloads opslaan helpt als een klant zegt: "wij hebben de update nooit gekregen." Je kunt verwerking opnieuw uitvoeren vanuit de opgeslagen body nadat je een bug hebt opgelost, zonder de provider te vragen opnieuw te verzenden.
Concurrency: correct blijven onder parallelle requests
Herproberen wordt ingewikkeld wanneer twee requests met dezelfde idempotentiesleutel tegelijk binnenkomen. Als beide handlers de "do work"-stap uitvoeren voordat een van beide het resultaat opslaat, kun je alsnog dubbel afschrijven, dubbel importeren of dubbel enqueuen.
Het eenvoudigste coördinatiepunt is de database-transactie. Maak de eerste stap "claim de sleutel" en laat de database beslissen wie wint. Veel opties:
- Unieke insert in een dedup-tabel (de database handhaaft één winnaar)
SELECT ... FOR UPDATEnadat je de dedup-rij hebt aangemaakt of gevonden- Transaction-level advisory locks gehasht op de idempotentiesleutel
- Unieke constraints op het businessrecord als laatste backstop
Voor langlopende taken, vermijd het vasthouden van een row-lock terwijl je externe systemen aanroept of minutenlange imports runt. Sla in plaats daarvan een kleine state machine op in de dedup-rij zodat andere verzoeken snel kunnen uitstappen.
Een praktisch setje staten:
in_progressmetstarted_atcompletedmet gecachte responsefailedmet een foutcode (optioneel, afhankelijk van je retrybeleid)expires_at(voor cleanup)
Voorbeeld: twee app-instances ontvangen hetzelfde payment-verzoek. Instance A inserted de sleutel en markeert in_progress, daarna roept hij de provider aan. Instance B raakt het conflict-pad, leest de dedup-rij, ziet in_progress en geeft een snelle "nog in verwerking"-response (of wacht kort en checkt opnieuw). Wanneer A klaar is, update hij de rij naar completed en slaat de response body op zodat latere retries exact dezelfde output krijgen.
Veelgemaakte fouten die idempotentie breken
De meeste idempotentie-bugs gaan niet over ingewikkelde locking. Het zijn "bijna correcte" keuzes die falen bij herpogingen, timeouts of twee gebruikers die vergelijkbare acties doen.
Een valkuil is de idempotentiesleutel als globaal uniek beschouwen. Als je hem niet scope't (per user, account of endpoint), kunnen twee verschillende clients in botsing komen en elkaars result zien.
Een ander probleem is het accepteren van dezelfde sleutel met een andere request body. Als de eerste call voor $10 was en de replay voor $100, moet je niet stilletjes het eerste resultaat teruggeven. Sla een request-hash op (of belangrijke velden) en vergelijk bij replay; geef een duidelijke conflict-fout bij mismatch.
Clients raken ook verward als replays een andere response-shape of statuscode teruggeven. Als de eerste call 201 teruggaf met een JSON-body, moet de replay dezelfde body en statuscode teruggeven. Veranderend replay-gedrag dwingt clients te gokken.
Fouten die vaak duplicaten veroorzaken:
- Alleen vertrouwen op een in-memory map of cache, en dedup-state verliezen bij een restart.
- Een sleutel zonder scope gebruiken (cross-user of cross-endpoint botsingen).
- Niet valideren van payload-mismatches voor dezelfde sleutel.
- Eerst de bijwerking uitvoeren (charge, insert, publish) en pas daarna het dedup-record schrijven.
- Bij elke retry een nieuw gegenereerd ID teruggeven in plaats van het originele resultaat te replayen.
Een cache kan reads versnellen, maar de bron van waarheid moet duurzaam zijn (meestal PostgreSQL). Anders kunnen retries na een deploy duplicaten veroorzaken.
Plan ook cleanup. Als je elke sleutel voor altijd bewaart, groeien tabellen en vertragen indexes. Stel een retentievenster in gebaseerd op echt retry-gedrag, verwijder oude rijen en houd de unieke index klein.
Snelle checklist en vervolgstappen
Behandel idempotentie als onderdeel van je API-contract. Elk endpoint dat mogelijk door een client, queue of gateway herhaald kan worden, heeft een duidelijke regel nodig voor wat "hetzelfde verzoek" betekent en hoe "hetzelfde resultaat" eruitziet.
Een checklist voordat je live gaat:
- Voor elk retrybaar endpoint: is de idempotentie-scope gedefinieerd (per user, account, order, extern event) en opgeschreven?
- Wordt dedup afgedwongen door de database (unieke constraint op idempotentie-sleutel en scope), en niet alleen in code gecontroleerd?
- Geef je bij replay dezelfde statuscode en response body terug (of een gedocumenteerde, stabiele subset), niet een vers object of nieuwe timestamp?
- Voor betalingen: handel je onbekende uitkomsten veilig af (timeout na submit, gateway zegt "processing") zonder dubbel te schrijven?
- Maken logs en metrics duidelijk wanneer een verzoek voor het eerst gezien is vs wanneer het een replay is?
Als een item een "misschien" is, los het nu op. De meeste fouten verschijnen onder stress: parallelle retries, trage netwerken en gedeeltelijke uitval.
Als je interne tools of klantgerichte apps bouwt op AppMaster (appmaster.io), helpt het om idempotentiesleutels en de PostgreSQL-dedup-tabel vroeg te ontwerpen. Zo blijft je retry-gedrag consistent, zelfs als het platform Go-backendcode regenerereert wanneer eisen veranderen.
FAQ
Hertussen zijn normaal: netwerken en clients falen op gewone manieren. Een verzoek kan op de server wél slagen maar de response bereikt de client niet, waarna de client opnieuw probeert en de server hetzelfde werk nogmaals doet tenzij die het verzoek herkent en het oorspronkelijke resultaat replayt.
Stuur dezelfde sleutel bij elke herpoging van dezelfde actie. Laat de client de sleutel genereren als een willekeurige, niet-raadbare string (bijvoorbeeld een UUID) en gebruik die niet voor een andere actie.
Scoop het af op manieren die bij je business passen: meestal per endpoint plus een caller-identiteit zoals user, account, tenant of API-token. Zo voorkom je dat twee verschillende klanten toevallig dezelfde sleutel gebruiken en elkaars resultaten zien.
Geef hetzelfde resultaat terug als de eerste succesvolle poging. In de praktijk betekent dat: replay dezelfde HTTP-statuscode en dezelfde response body, of minstens hetzelfde resource-ID en dezelfde status, zodat clients veilig kunnen herproberen zonder een tweede bijwerking te veroorzaken.
Weiger het met een duidelijk conflict-achtige fout in plaats van te gokken. Sla een hash op van de belangrijke requestvelden en vergelijk die; als de sleutel gelijk is maar de payload anders, faal dan snel om te voorkomen dat twee verschillende acties onder dezelfde sleutel worden samengevoegd.
Bewaar sleutels lang genoeg om realistische herpogingen te dekken, en verwijder ze daarna. Veel gebruikte standaarden zijn 24–72 uur voor betalingen, een week voor imports, en voor webhooks houd je je aan het retrybeleid van de sender zodat late herpogingen nog steeds dedupe werken.
Een aparte dedup-tabel werkt goed: de database kan een unieke constraint afdwingen en overleeft restarts. Sla scope (owner), sleutel, request-hash, status en de response op om te replayen; maak (owner, key) uniek zodat slechts één verzoek de "winner" is.
Claim de sleutel binnen een database-transactie en voer de bijwerking alleen uit als je de sleutel succesvol claimt. Als een ander verzoek parallel binnenkomt, moet dat de unieke constraint raken, in_progress of completed zien en een wacht-/replay-respons teruggeven in plaats van de logica nogmaals uit te voeren.
Behandel timeouts als “onbekend”, niet als “mislukt”. Registreer een pending-status en, als je een provider-ID hebt, gebruik die als bron van waarheid zodat herpogingen hetzelfde betalingsresultaat teruggeven in plaats van een nieuwe afschrijving te maken.
Dedup op twee niveaus: job-niveau en item-niveau. Laat herpogingen hetzelfde import-job-ID teruggeven, en zorg voor een natuurlijke sleutel voor rijen (zoals een external ID of (account_id, email)) met unieke constraints of upserts zodat reprocessing geen duplicaten maakt.


