Incident-walkthrough
Idempotency keys en Snowflake retries: SEPA-postmortem
Een reconciliatie-agent in Gent boekte negen uur lang stilzwijgend 1.180 SEPA-transacties dubbel. Oorzaak: een idempotency-key gekoppeld aan een gerouleerde session-UUID.

De ochtend dat de boekhouding ging schuiven
Op een dinsdagochtend om 09:14 opende het hoofd operations van een 32-koppige fintech in Gent haar reconciliatie-dashboard en zag een getal dat ze niet herkende. De nachtelijke agent had 1.180 regels meer in het grootboek geschreven dan het inkomende SEPA-bestand daadwerkelijk bevatte. Elke klantbalans verderop in de keten klopte niet, sommige met een paar centen, sommige met vier cijfers. Tegen de tijd dat we de oorzaak zes uur later hadden teruggevonden, kwam het neer op één regel SQL binnen de afleiding van een idempotency-key.
De pipeline had de avond ervoor om 22:00 schoon gedraaid. Het dashboard om 23:30 was groen geweest. Ergens tussen middernacht en het ontbijt had iets stilzwijgend de meeste boekingen van die nacht verdubbeld, en niets in de logs van de agent zelf gaf het als afwijkend aan.
We brachten de rest van die ochtend door met hun CTO over een gedeeld scherm om de rommel uit te zoeken. De eerste anderhalf uur ging op aan triage. Het hoofd operations trok drie klanten naar voren wier balansen meer dan 1.000 euro afweken en zette handmatig holds op hun uitgaande betalingen. Hun CTO bevroor het ochtendlijke uitgaande SEPA-bestand voordat het de SFTP-drop richting de bank verliet. De resterende vier uur zaten we in het warehouse, achterstevoren werkend van de dubbele rijen naar de oorzaak. Dat de bug leefde in een stored procedure die in achttien maanden niemand had aangeraakt, maakte hem moeilijker te vinden, niet makkelijker. Engineers vertrouwen dragende code die al lang stil is.
De architectuur vóór de storing
De fintech draait een dagelijkse betaal-reconciliatie-agent in drie lagen. Een Snowflake task graph haalt op een vast tijdstip de ochtendlijke SEPA pain.002- en camt.054-bestanden op van een SFTP-drop. Een stored procedure parseert ze en draait een MERGE naar een recon_ledger-tabel. Een classificatie-agent in Python loopt vervolgens door het grootboek, markeert afwijkingen en zet alles wat verdacht is in de wachtrij voor een menselijke reviewer.
De MERGE was de dragende stap. De Python-agent draaide op de aanname dat het grootboek canoniek, ontdubbeld en bevroren was tegen de tijd dat de classifier begon te scannen. Die aanname had achttien maanden standgehouden, wat de slechtste soort aanname is. Het soort dat zichzelf in het ontwerp verderop bakt zonder dat iemand het opschrijft.
Ontdubbeling leunde op een idempotency-key die per rij door de MERGE werd weggeschreven. Als dezelfde SEPA end-to-end-identifier twee keer langskwam, zou de key botsen en zou de tweede insert worden overgeslagen. Een standaard, platgetreden patroon.
De retry die niemand zag
Om 00:14 liep de eerste task in de graph tegen een tijdelijke credential-refresh-fout op tijdens het lezen van SFTP. De connector haalt credentials op uit een Snowflake external token integration, en het access token was twee minuten eerder verlopen dan de gecachte expiry. Het retry-framework ving de fout op, ververste het token en draaide de task opnieuw binnen het ingestelde venster. Achtenzeventig seconden later was de tweede poging schoon afgerond.
Dit is precies hoe het task-framework van Snowflake zich hoort te gedragen. Tijdelijke fouten retryen binnen een venster, alleen aanhoudende fouten escaleren. Het team had zowel het retry-aantal als het suspension-gedrag bewust geconfigureerd, na een eerdere storing waarbij een credential-issue de pipeline een volle ochtend had laten stilliggen. De retry was geen bug. Het was het systeem dat zijn werk deed.
Wat het team niet doorhad, was dat de retry binnen een gloednieuwe session werd uitgevoerd. Snowflake geeft elke task-uitvoering een nieuwe session-identifier, of het nu een eerste poging is of een retry. En de afleiding van de idempotency-key was afhankelijk van de session.
De herkomst van de stukke key
Verstopt in een stored procedure die achttien maanden eerder was geschreven, zat dit fragment SQL:
INSERT INTO recon_ledger (idem_key, end_to_end_id, amount_cents, value_date, raw)
SELECT
SHA2(CONCAT_WS('|', CURRENT_SESSION(), s.end_to_end_id), 256) AS idem_key,
s.end_to_end_id,
s.amount_cents,
s.value_date,
s.raw_payload
FROM staging_sepa_inbound s
LEFT JOIN recon_ledger r
ON r.idem_key = SHA2(CONCAT_WS('|', CURRENT_SESSION(), s.end_to_end_id), 256)
WHERE r.idem_key IS NULL;
De oorspronkelijke auteur had naar CURRENT_SESSION() gegrepen om de key te scopen aan "deze run". Zijn redenering, die we die avond uit een Slack-draadje terughaalden, was dat hij tijdens ontwikkeling een session opnieuw wilde kunnen draaien en zijn eigen writes wilde laten overschrijven. Het patroon dat hij voor ogen had, was een developer die de procedure twee keer vanuit de Snowflake UI draait, binnen één session schoon idempotent gedrag krijgt, en duidelijke scheiding tussen debug-sessies houdt.
Die intentie ging stuk op het moment dat de procedure binnen een geretryde task draaide. In de praktijk kreeg elke session een vers UUID, en dezelfde SEPA-transactie die door een geretryde session liep, produceerde een andere key dan diezelfde transactie in de oorspronkelijke session. De twee runs waren nu niet meer te onderscheiden van twee echt verschillende inkomende bestanden die toevallig dezelfde end-to-end-identifier bevatten.
De eerste run schreef 1.180 rijen weg met key SHA2('uuid-A|EE-id', 256). De geretryde run, op zoek naar botsingen op de nieuwe key SHA2('uuid-B|EE-id', 256), vond er geen. Hij voegde vrolijk 1.180 rijen toe. De MERGE was niet kapot. De key was kapot.
Als je idempotency-key verwijst naar de session, de connection, de worker of de wall-clock-minuut van uitvoering, dan is het geen idempotency-key. Het is een ruisgenerator vermomd als hashfunctie.
De blast radius van negen uur
Vanaf 00:14 tot het hoofd operations om 09:14 het verschil opmerkte, behandelden alle downstream-consumers van recon_ledger de dubbele rijen als echt. Het treasury-rapport dat om 06:00 werd gegenereerd, rapporteerde 437.000 euro aan inflows te veel. Klantgerichte balans-updates gingen om 07:30 uit met opgeblazen bedragen. Drie klanten kregen hun automatische SEPA-incassomandaten opnieuw getriggerd, omdat de classificatie-agent de dubbele boeking zag als bewijs van een onderbetaling in de oorspronkelijke transactie.
Eén van die mandaten zou voor een bedrag met vier cijfers van een klantrekening worden afgeschreven op de ochtend van een nationale feestdag in België, nadat de klantenservice al gesloten was. We vingen het op om 10:48, met zeventig minuten speling vóór de dagelijkse SEPA-cutoff naar de bank. De andere twee waren rechttoe rechtaan. De derde is de reden dat dit team sindsdien een harde limiet heeft op het automatisch triggeren van mandaten binnen twaalf uur na een recente batch-grens.
De classificatie-agent deed precies waar hij voor was ontworpen. Hij deed het alleen bovenop een corrupte tabel.
De blinde vlek van de classifier
De Python-classifier was zorgvuldig gebouwd. Hij markeerde ongebruikelijke bedragen, onbekende tegenpartijen en timing-patronen die niet bij de historie van een klant pasten. Hij controleerde niet of dezelfde end-to-end-identifier twee keer in dezelfde batch voorkwam. Dat uniciteitscontract was gedelegeerd aan Snowflake, en Snowflake was er stilzwijgend mee gestopt.
Dit is het deel dat teams pakt die agentic workflows bovenop datawarehouses uitrollen. De redenering van de agent is alleen zo betrouwbaar als de ondergrond waar hij op staat. Als het warehouse uniciteit belooft en stilletjes ophoudt te leveren, blijft het vertrouwen van de agent hoog en blijft zijn output plausibel. Plausibel-maar-fout is de slechtste failure mode voor elk redenerend systeem. De interessante metriek is niet "heeft de agent geantwoord". Het is "heeft de agent geantwoord op state die ook daadwerkelijk klopte".
Wat de dashboards eigenlijk vertelden
Het reconciliatie-dashboard stond van begin tot eind op groen. De task graph ook. Beide monitoring-oppervlakken maten hetzelfde: voltooide de code zonder een fout te gooien. Geen van beide mat de enige metriek die er in dit incident toe deed, namelijk of de tabel waar de code naartoe schreef nog steeds aan haar eigen integriteitscontract voldeed.
Dit is het gat dat teams pakt die welke geautomatiseerde pipeline dan ook bouwen, agentic of niet. De ondergrond onder de agent doet beloftes die niets actief verifieert. Een schema-migratie verwijdert een unique-constraint waar niemand op monitort. Een retry-framework verandert session-identiteit op een manier die een impliciete aanname breekt. Een connector-library wordt geüpdatet en begint writes anders te batchen. Geen van deze komt rood naar voren totdat iets verderop omvalt.
Het team had row-count-monitoring op het grootboek, maar niet op de verhouding van unieke end-to-end-identifiers ten opzichte van het totaal aantal rijen. Die check voegden ze diezelfde middag toe. Het zou de duplicaat binnen vijftien minuten na de tweede insert hebben gevangen.
1.180 duplicaten opruimen om 11 uur 's ochtends
Snowflake Time Travel redde de dag, zoals zo vaak. We trokken de tabel-state op van 23:55 de avond ervoor en diften die tegen de huidige state. De duplicaten waren makkelijk te identificeren zodra we stopten idem_key te vertrouwen als uniciteits-signaal en de natuurlijke samengestelde key uit het SEPA-bericht zelf gebruikten. De diff liet exact 1.180 rijen zien die twee keer waren ingevoegd, byte-voor-byte identiek qua inhoud op de inserted_at-timestamp en de stukke idem_key-kolom na.
CREATE OR REPLACE TABLE recon_ledger_clean AS
SELECT
end_to_end_id,
amount_cents,
value_date,
creditor_iban,
debtor_iban,
ANY_VALUE(raw_payload) AS raw_payload,
MIN(inserted_at) AS inserted_at
FROM recon_ledger
WHERE inserted_at >= '2026-06-08 22:00:00'
GROUP BY end_to_end_id, amount_cents, value_date, creditor_iban, debtor_iban;
Zodra het grootboek schoon was, liepen we de keten af. Het treasury-rapport regenereerde automatisch. De drie getriggerde incassomandaten werden geannuleerd vóór settlement op T+1. Twee klantgerichte balans-updates moesten met de hand worden teruggedraaid, met een korte excuusmail die dezelfde middag uitging.
Totale kosten van het incident in echt geld: een gedeeltelijke terugbetaling aan één klant die een overboekingsbeslissing had genomen op basis van de opgeblazen balans, plus een lange dag engineering. Totale kosten in vertrouwen: lastiger te meten, en de reden dat dit team het komende kwartaal zal besteden aan het wegschuren van elke andere impliciete aanname in de pipeline.
De gecorrigeerde idempotency-key
De fix was vier regels SQL. De idempotency-key wordt nu alleen afgeleid uit eigenschappen die intrinsiek aan het SEPA-bericht zijn en die stabiel blijven over willekeurig veel retries, sessions of workers heen:
SHA2(
CONCAT_WS('|',
s.end_to_end_id,
s.creditor_iban,
s.debtor_iban,
s.amount_cents,
TO_CHAR(s.value_date, 'YYYY-MM-DD')
),
256
) AS idem_key
Dit is het principe dat de notie van idempotentie in de HTTP-specificatie al vastlegt. De key moet een functie zijn van de request, niet van het verwerken ervan. De SEPA end-to-end-identifier is per scheme uniek per definitie; de omringende velden zijn dubbele zekerheid tegen kapotte input en in elkaar gepuzzelde hoekgevallen.
Het team voegde ook een UNIQUE-constraint toe op de natuurlijke samengestelde key in het grootboek zelf. Vanaf nu zal, zelfs als de agent-code opnieuw afdrijft, de database weigeren te liegen. Een mislukte insert is luid. Een stille duplicaat is degene die je negen uur kost.
Dezelfde naad in ons eigen werk
Toen we afgelopen kwartaal de reconciliatie-agent bouwden voor een Nederlandse logistieke klant, liepen we tegen een net iets andere versie van hetzelfde probleem aan: een Kafka consumer-group-rebalance waardoor één bericht binnen tien milliseconden door twee verschillende workers werd verwerkt. We losten het op zoals het team in Gent het uiteindelijk ook deed, door elke idempotency-key in een Postgres UNIQUE-constraint te schrijven met ON CONFLICT DO NOTHING, en die key alleen af te leiden uit de content-hash van het bericht. Als je AI-agents bouwt die geld raken, is dit de naad waarlangs de meeste stille storingen vroeg of laat naar boven komen.
Eén ding dat je vandaag kunt doen
Open je reconciliatie-, facturatie- of events-pipeline en grep de source op SESSION, UUID(), NOW(), CURRENT_TIMESTAMP, of welke random-number-call dan ook binnen de afleiding van een idempotency-key. Vind je er een, dan heb je dezelfde bug die wacht op een tijdelijke fout om hem naar boven te halen. Vervang hem door een hash van de input-velden, voeg een unique-constraint toe aan de doel-tabel, en slaap beter op dinsdag.
Kern
Een idempotency-key moet een functie zijn van de request, niet van de session die hem verwerkt. Al het andere is een ruisgenerator vermomd als hashfunctie.
FAQ
Waarom veroorzaakte de Snowflake-task-retry het duplicaat?
De retry deed precies wat hij moest doen. De bug zat erin dat de idempotency-key uit CURRENT_SESSION() werd berekend, waardoor elke retry een andere key opleverde voor dezelfde transactie en de dedup-check er overheen keek.
Waar moet een idempotency-key dan wel uit worden afgeleid?
Alleen uit eigenschappen die intrinsiek zijn aan het bericht dat verwerkt wordt: business-identifiers, bedragen, datums, tegenpartijen. Nooit uit de session, de worker, de timestamp van uitvoering, of een willekeurige waarde die runtime wordt gegenereerd.
Had de classificatie-agent de duplicaten kunnen vangen?
Ja, met een uniciteitscheck op end-to-end-identifiers per batch. Het team had dat contract gedelegeerd aan Snowflake. Toen het warehouse stopte met handhaven, had de agent geen achtervang om het op te merken.
Hoe hebben jullie de oorspronkelijke state van het grootboek hersteld?
Met Snowflake Time Travel. Het team bevroeg de tabel zoals die om 23:55 de avond ervoor bestond, diftde hem tegen de huidige state, en bouwde het schone grootboek opnieuw op door te groeperen op de natuurlijke SEPA-samengestelde key.