Process automation
Idempotente webhooks: 87 dubbele slots bij ECT Delta
Op maandag 9 juni vielen 87 bevestigingen van ECT Delta voor 07:14 binnen bij een Rotterdamse expediteur. Drie regels code, elf dagen eerder gemerged.

De planner van een Rotterdamse expediteur met 21 mensen opende op maandag 9 juni om 07:14 haar inbox en zag 87 bevestigingen van ECT Delta. Dezelfde containers. Andere slot-ID's. Sommige slots zaten 40 minuten uit elkaar op hetzelfde chassis. Haar eerste telefoontje was naar de terminalplanner. Het tweede naar ons. De oorzaak: drie regels code die elf dagen eerder waren gemerged, aan de verkeerde kant van de grens tussen at-least-once delivery en exactly-once processing.
Tegen 09:30 had het team 71 van de 87 spookboekingen geannuleerd, €4.250 aan annuleringskosten betaald aan de terminal, en op drie reefers het pickup-window van een CSCL-schip gemist omdat het chassis dat die reefers nodig hadden was weggetrokken naar een dubbel slot en niet op tijd terug was. De resterende zestien slots konden niet meer schoon geannuleerd worden voordat hun window sloot; die hebben we ook moeten betalen. Totale schade voor de lunch: ongeveer €6.800, plus een klant die wilde weten waarom zijn temperatuurgevoelige lading nu op de manifest van morgen stond.
Dit is de walkthrough. We delen het omdat dezelfde fout op dit moment in veel terminal-integraties zit, en de oplossing is mechanisch.
Het tijdspad van die maandag
Vrijdag 30 mei, 16:42 — een junior bij ons levert een change op de process-automation agent van het bedrijf. Portbase had een Cargo Information Service-notificatie verstuurd voor een binnenkomende container; de agent claimt een slot bij ECT Delta, dient de PIN in en schrijft de boeking terug naar het TMS. Standaardflow. De junior verhuist de webhook handler van directe Lambda-invocation naar een SQS Standard queue "voor retry-veiligheid". Het komt door review. Het komt door de integratietests.
Maandag 9 juni, 02:11 — Portbase begint notificaties af te leveren voor een drukker dan normale maandagochtend. SQS doet wat SQS Standard queues doen: at-least-once delivery. Voor zeventien berichten die ochtend dropt de broker dezelfde notificatie twee keer, drie keer, in één geval zes keer. De agent verwerkt elke delivery als een nieuwe job en claimt een nieuw slot. Om 07:00 staan er bij de terminal 87 afspraken tegen 39 containers.
Maandag 9 juni, 06:51 — de pager van de on-call engineer gaat af. Niet vanuit onze monitoring; die zag niets fout. De melding kwam van het TMS-team, dat een ongebruikelijk aantal slot-ID's tegen één container had zien verschijnen. Tegen de tijd dat iemand naar de logs van de agent keek, was de schade gedaan. De agent had precies gedaan wat hem was opgedragen.
De fout is niet exotisch. Het is het meest gedocumenteerde gedrag in AWS:
"Standard queues provide at-least-once delivery, which means that each message is delivered at least once. Occasionally (because of the highly distributed architecture that allows nearly unlimited throughput), more than one copy of a message might be delivered out of order."
AWS SQS Developer Guide
De junior wist dat. Iedereen weet dat. Wat hij miste: "exactly-once" is geen eigenschap van de queue. Het is een eigenschap van de consumer. En op ons slot-claim endpoint stond niks.
Anatomie van het dubbele bericht
Hier is de slechte versie, vereenvoudigd zodat ie op één pagina past.
async def handle_portbase_event(msg):
payload = json.loads(msg.body)
container = payload["equipment_number"]
slot = await ect.claim_slot(
container=container,
window=payload["preferred_window"],
)
await tms.write_booking(container, slot.id)
await msg.delete()Drie problemen, en ze versterken elkaar.
Eén: ect.claim_slot heeft geen idempotency key. De slot-API van ECT Delta geeft je vrolijk een nieuw slot voor dezelfde container, elke keer dat je belt. Vanaf de kant van de terminal ben je 87 verschillende zendingen die toevallig allemaal hetzelfde equipment number delen.
Twee: de write naar het TMS gebeurt ná de slot-claim. Als de consumer crasht tussen die twee calls — en dat deed hij, twee keer die ochtend, door een hapering op de database connection — herlevert SQS, claimt de volgende worker een nieuw slot, en hoort het TMS nooit van de eerste.
Drie: msg.delete() draait alleen op het happy path. Elke exception laat het bericht na de visibility timeout terugkeren in de queue. By design. Zonder verdere maatregelen is elke retry een nieuwe boeking.
De oplossing is niet "switch over naar FIFO met content-based deduplication". FIFO had geholpen aan de duplicate-delivery kant, maar de agent zelf kan nog steeds twee slots claimen voor dezelfde container op twee verschillende berichten: een redrive, een handmatige replay, een webhook die Portbase twee uur later opnieuw afvuurt vanuit z'n eigen retry-logica. De fix moet bij de consumer zitten.
De exactly-once gate in vier stappen
Na het incident hebben we de gate uitgeschreven en draaien we hem nu op elke terminal-integratie. Vier stappen, in deze volgorde. Eentje overslaan zet het gat weer open.
1. Leid een stabiele idempotency key af aan de rand
De key is niet het SQS message ID. SQS message-ID's zijn uniek per delivery, niet per event. Gebruik een key die het upstream systeem onder controle heeft. Voor Portbase CIS is dat het notificationId op de envelope; voor ECT slot-claims hashen we (equipment_number, requested_window_start, agent_run_id).
def idempotency_key(event):
raw = f"{event['notificationId']}:{event['equipment_number']}"
return hashlib.sha256(raw.encode()).hexdigest()De agent hangt deze key aan elke downstream call die hij voor dit event maakt. Zelfde event, zelfde key, elke keer, voor altijd.
2. Reserveer de key vóór elk side effect
Een kleine Postgres-tabel met een unique index. Of DynamoDB met een conditional PutItem. De clou is dat de insert faalt als de key al bestaat, atomair, zonder read-then-write race.
INSERT INTO idempotency_log (key, status, created_at)
VALUES ($1, 'in_flight', now())
ON CONFLICT (key) DO NOTHING
RETURNING key;Als RETURNING niks teruggeeft, heeft een andere worker dit event al. Je ackt het bericht en stopt. Geen slot claimen. Geen write naar het TMS. Niks.
De reservering moet vóór de eerste externe call gebeuren, niet erna. We hebben teams gezien die de dedup-check na de slot-claim plaatsen "om eerst zeker te weten dat het werk gelukt is". Dat ís de bug. Je kunt een terminal-slot niet meer ongedaan maken zonder ervoor te betalen.
3. Geef de key door aan de terminal
ECT Delta accepteert een Idempotency-Key header op het slot endpoint. Dat doen de meeste moderne terminal-API's, en het bredere patroon vind je terug in de IETF-draft over de Idempotency-Key HTTP-header. Stuur de key mee. Ziet de terminal dezelfde key twee keer binnen z'n retentiewindow, dan geeft hij het oorspronkelijke slot terug, geen nieuwe. Dit is je vangnet tegen een redelivery die toch langs stap 2 wist te glippen: een clock skew, een replica lag, een handmatige replay tegen een vergeten queue.
slot = await ect.claim_slot(
container=container,
window=payload["preferred_window"],
headers={"Idempotency-Key": idem_key},
)Ondersteunt de terminal deze header niet, dan stopt de gate hier en escaleer je naar de integratie-eigenaar. Niet zelf afdekken met retries. Wij hebben één terminal waar we hierdoor nog steeds niet schoon op kunnen integreren; die boekingen routen we naar een menselijke queue en die kosten nemen we voor lief.
4. Commit de uitkomst en de key in één transactie
Schrijf de TMS-boeking en zet de status van de dedup-row op committed in dezelfde database-transactie. Als de worker halverwege sneuvelt, vindt de volgende retry de key in in_flight-status en draait een kleine reconciliation: vraag bij de terminal het slot op dat aan deze key hangt, schrijf het naar het TMS, markeer als committed. Voor een key die al een slot heeft, wordt nooit een nieuw slot geclaimd.
BEGIN;
UPDATE idempotency_log
SET status = 'committed', slot_id = $2
WHERE key = $1;
INSERT INTO tms_bookings (container, slot_id, source_event)
VALUES ($3, $2, $1);
COMMIT;Reconciliation is het stuk dat de meeste teams overslaan omdat het over-engineered voelt — tot de eerste keer dat het je redt. In onze gate wordt elke minuut een redrive-worker wakker en kijkt naar rijen die na twee minuten nog op in_flight staan. Voor elke rij belt hij het slot-lookup endpoint van de terminal met de oorspronkelijke idempotency key, pakt het slot dat de terminal al onder die key heeft staan, en sluit de oorspronkelijke transactie af. Dit pad is in twee maanden zes keer afgegaan — drie database-failovers, twee deploys die midden in een bericht landden, één network partition. In alle gevallen stond het juiste slot binnen negentig seconden in het TMS, zonder menselijke tussenkomst.
Wat je in de gaten houdt zodra de gate live is
Een gate faalt stil als je hem laat. Een bug in de key-derivation, een clock skew op de dedup-store, een deploy die de header op uitgaande calls laat vallen — geen van die dingen gooit een fout. Ze stoppen alleen met dedupliceren, en de volgende slechte maandag ziet er precies zo uit als de vorige. We hebben na het incident drie signalen toegevoegd en daar sindsdien twee regressies mee gevangen.
Eén: de verhouding tussen ON CONFLICT DO NOTHING-returns en het totaal aantal events. Op een stabiele Portbase-feed zit dat op 1–3%: Portbase vuurt prima notificaties op een laag achtergrondniveau opnieuw af, en dat wil je zien. Een daling naar nul betekent meestal dat de key-derivation stuk is. Een piek naar 20% betekent dat een upstream systeem zich misdraagt en wil je een melding.
Twee: de leeftijd van de oudste in_flight-rij in de idempotency log. Alles ouder dan het langste plausibele processing-window van de consumer is een gestrande job die de reconciliation allang had moeten afsluiten. Wij alerten op tien minuten.
Drie: het aantal verschillende slot-ID's dat de terminal teruggeeft voor dezelfde key binnen het dedup-window. Een gezonde gate krijgt er precies één. Twee betekent dat óf de terminal zijn eigen idempotency cache vergeten is, óf wij de verkeerde key hebben gestuurd, en in beide gevallen wil je dat uitzoeken vóórdat het zich opstapelt tot weer een inbox van 87.
Wat er veranderde voor de expediteur
We hebben de gate binnen 36 uur na het incident gedeployed. Twee maanden verder heeft dezelfde agent 14.800 Portbase-notificaties verwerkt zonder één dubbele boeking. De dedup-tabel zit op 180 MB. De latency-kosten zijn één Postgres roundtrip, zeg 4 ms op de p99, en dat is minder erg dan op een maandag €4.250 betalen.
Het bredere punt: at-least-once is een contract waarmee elke moderne queue komt. SQS Standard, Pub/Sub, Kafka, de webhook-retry van Portbase zelf, UI-replays in ECT Delta. Je zult hetzelfde event opnieuw zien. De enige vraag is of je consumer er klaar voor is als het zover is.
Exactly-once is geen feature van de queue. Het is discipline bij de consumer: leid een key af, reserveer hem, geef hem door, commit hem transactioneel.
Toen we de AI-agents bouwden voor die Rotterdamse expediteur, liepen we ertegenaan dat niemand — niet wij, niet de terminal, niet Portbase — het end-to-end exactly-once verhaal in handen had. Uiteindelijk hebben we de gate geschreven als een kleine library die al onze terminal-integraties nu vanaf dag één importeren, en we controleren erop bij elke code review.
Draai je vandaag een terminal-integratie, dan is het kleinste nuttige wat je vanmiddag kunt doen: grep je consumer op de call die iets boekt, afschrijft of extern claimt, en kijk of de regel erboven een idempotency key reserveert. Zo niet, dan zit je met dezelfde bug. Fix die eerst.
Kern
Exactly-once is geen eigenschap van de queue. Het is discipline bij de consumer: leid een key af, reserveer hem, geef hem door, commit transactioneel.
FAQ
Waarom dan niet gewoon overstappen van SQS Standard naar SQS FIFO?
FIFO met content-based dedup helpt binnen een venster van vijf minuten, maar de agent zelf kan nog steeds twee slots claimen voor dezelfde container op twee echt verschillende events: een handmatige replay, een Portbase-re-fire twee uur later, een redrive vanuit de DLQ.
Waar hoort de idempotency store te draaien?
Overal waar je een atomaire conditional insert kunt doen. Wij gebruiken Postgres met een unique index op de key-kolom. DynamoDB met een conditional PutItem werkt net zo goed. Redis SETNX is prima voor korte windows, maar je verliest de audit trail.
Wat als de terminal-API geen Idempotency-Key header accepteert?
Stop bij stap 2 en route die boekingen via een menselijke goedkeurings-queue. Niet zelf retryen. Zonder server-side herkenning van de key wordt elke retry een gegarandeerde dubbele zodra je dedup-store ernaast zit.
Hoe lang houden we idempotency keys bij?
Lang genoeg om elke upstream retry-policy waarvan je afhankelijk bent te overleven. Voor Portbase CIS plus SQS plus operator-replays is veertien dagen de ondergrens. Wij bewaren negentig dagen voor forensisch onderzoek en partitioneren de tabel per maand voor cleanup.