Webhooks: idempotens, fejl og indhentning
Svar hurtigt, dedupliker på entity og sekvensnummer, hent det tabte med hændelser, og overvåg failure_count, før webhooken slås fra.
Webhooks er praktiske, men en levering kan gå tabt, og den bliver ikke sendt igen. Denne guide viser, hvordan du bygger en modtager, der tåler det: den svarer hurtigt, behandler den samme levering én gang, og henter alt, hvad den måske har misset, fra hændelses-API'et.
Sådan leverer vi
| Regel | Værdi |
|---|---|
| Hvornår | Når vores synkronisering har hentet nye ændringer, omtrent én gang i timen. Én levering pr. ændret entitet |
| Timeout | 6 sekunder for hele leveringen |
| Succes | HTTP-status under 400 |
| Fejl | Status 400 eller højere, timeout eller forbindelsesfejl |
| Nye forsøg | Ingen. En fejlet levering sendes ikke igen |
| Fejltæller | failure_count stiger med 1 pr. fejl og nulstilles ved første succes |
| Slås fra | Efter 15 fejl i træk sættes is_active til false, og der sendes ikke flere leveringer |
To ting følger af tabellen:
- Webhooken er et signal, ikke selve data. Du kan ikke regne med at få hver levering. Brug den til at vide, at der er noget nyt, og hent ændringerne med
/replikering/haendelser?entitet=…fra din egen cursor. Så får du også det med, som en tabt levering skulle have fortalt. - En redirect tæller som succes. En 301 eller 302 er under 400, så den nulstiller
failure_count, men indholdet når aldrig frem. Peg webhooken direkte på den endelige URL.
Der sendes ingen mail, når en webhook slås fra. Det er derfor, du skal overvåge den selv, se længere nede.
1. Svar hurtigt, arbejd bagefter
Seks sekunder er nok til at verificere signaturen og gemme leveringen, men ikke nødvendigvis til at hente hændelser og opdatere din database. Del det i to: en modtager, der gemmer og svarer 204, og en worker, der gør arbejdet.
# modtager.py (FastAPI): verificér, dedupliker, gem i en indbakke, svar 204 import hashlib import hmac import json import os import sqlite3 from fastapi import FastAPI, Header, HTTPException, Request, Response app = FastAPI() SECRET = os.environ["DANADRESSE_WEBHOOK_SECRET"] db = sqlite3.connect("adresser.db", check_same_thread=False) db.execute("""CREATE TABLE IF NOT EXISTS webhook_inbox ( entity TEXT NOT NULL, sekvensnummer INTEGER NOT NULL, body TEXT NOT NULL, modtaget TEXT NOT NULL DEFAULT (datetime('now')), behandlet INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (entity, sekvensnummer) )""") db.commit() @app.post("/hooks/danadresse", status_code=204) async def hook(request: Request, x_danadresse_signature: str | None = Header(default=None)) -> Response: raw = await request.body() expected = hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest() if not x_danadresse_signature or not hmac.compare_digest( expected.encode(), x_danadresse_signature.strip().lower().encode()): raise HTTPException(status_code=401) p = json.loads(raw) # Primærnøglen (entity, sekvensnummer) gør det idempotent: en gentaget # levering rammer INSERT OR IGNORE og ændrer intet. db.execute("INSERT OR IGNORE INTO webhook_inbox (entity, sekvensnummer, body) VALUES (?, ?, ?)", (p["entity"], p["sekvensnummer"], raw.decode())) db.commit() return Response(status_code=204) # hurtigt svar: arbejdet sker i worker.py
Primærnøglen (entity, sekvensnummer) gør modtageren idempotent. Den samme levering to gange, fx fordi en proxy gentog den, ender som én række.
2. Workeren henter fra din cursor
Workeren bruger synk.py fra Replikering, del 2. For hver entitet i indbakken henter den alle hændelser fra din cursor op til leveringens sekvensnummer:
# worker.py: behandl indbakken ved at hente hændelser fra din egen cursor. # Genbruger synk.py fra guiden "Replikering, del 2". Kør fx hvert minut fra cron. import sqlite3 from synk import ENTITETER, synk_entitet db = sqlite3.connect("adresser.db") ventende = db.execute( "SELECT entity, max(sekvensnummer) FROM webhook_inbox WHERE behandlet = 0 GROUP BY entity" ).fetchall() for entity, hoejeste in ventende: if entity not in ENTITETER: continue # Hent ALT fra din cursor op til leveringens sekvensnummer, ikke kun de id'er, # der stod i `changes`. Så fanger du også det, en tabt levering skulle have sagt. antal = synk_entitet(db, entity, ENTITETER[entity], hoejeste) db.execute("UPDATE webhook_inbox SET behandlet = 1 WHERE entity = ? AND sekvensnummer <= ?", (entity, hoejeste)) db.commit() print(f"{entity}: {antal} hændelser til og med {hoejeste}")
Hvorfor er changes ikke nok?
changeshar højst 1.000 id'er.countfortæller, om der var flere.- For
vejstykkeindeholderchangeskun kommunekoden, ikke vejkoden, så du kan ikke se, hvilken vej der er ændret. changessiger ikke, hvad der er ændret, eller om rækken er nedlagt. Det står i hændelserne.- Hændelserne fra din cursor dækker også de leveringer, du aldrig fik.
Fordi synk_entitet aldrig flytter cursoren baglæns og anvender hændelser som upserts, er det ufarligt at køre workeren flere gange eller at behandle leveringer i forkert rækkefølge.
3. Hav et sikkerhedsnet
Selv med webhooks bør du køre den almindelige synkronisering fra cron, fx én gang i timen eller én gang i døgnet. Den fanger ændringer i perioder, hvor din server var nede, og ændringer for en webhook, der er slået fra:
# hvert minut: behandl indbakken (ingen API-kald, når den er tom) * * * * * cd /srv/adresser && flock -n worker.lock python worker.py >> worker.log 2>&1 # hver time: fuld indhentning som sikkerhedsnet 7 * * * * cd /srv/adresser && flock -n synk.lock python synk.py >> synk.log 2>&1
Workeren bruger kun API-kald, når der ligger noget i indbakken. Synkroniseringen bruger ét kald til senestesekvensnummer plus mindst ét kald pr. entitet.
4. Overvåg webhooken
GET /api/v1/webhooks viser is_active, failure_count og last_delivery_at for hver webhook. Tjek dem dagligt:
# overvaag_webhooks.py: kør dagligt; exit 1 (og en linje pr. problem), hvis noget er galt import datetime as dt import os import sys import requests BASE_URL = os.environ.get("DANADRESSE_BASE_URL", "https://api.danadresse.dk") STILLE_TIMER = 48 # alarmér, hvis en aktiv webhook ikke har fået noget i så lang tid token = requests.post(f"{BASE_URL}/api/v1/auth/login", timeout=10, json={ "email": os.environ["DANADRESSE_EMAIL"], "password": os.environ["DANADRESSE_PASSWORD"], }).json()["access_token"] r = requests.get(f"{BASE_URL}/api/v1/webhooks", headers={"Authorization": f"Bearer {token}"}, timeout=10) r.raise_for_status() problemer = [] nu = dt.datetime.now(dt.timezone.utc) for w in r.json()["webhooks"]: navn = f"{w['url']} ({','.join(w['entities'])})" if not w["is_active"]: problemer.append(f"SLÅET FRA efter {w['failure_count']} fejl i træk: {navn}. Slet og opret igen.") elif w["failure_count"] > 0: problemer.append(f"{w['failure_count']} fejl i træk: {navn}") if w["is_active"] and w["last_delivery_at"]: sidst = dt.datetime.fromisoformat(w["last_delivery_at"]) if sidst.tzinfo is None: sidst = sidst.replace(tzinfo=dt.timezone.utc) if nu - sidst > dt.timedelta(hours=STILLE_TIMER): problemer.append(f"ingen levering siden {sidst:%Y-%m-%d %H:%M} UTC: {navn}") for p in problemer: print(p) sys.exit(1 if problemer else 0)
Sæt STILLE_TIMER efter de entiteter, du følger. Adresser ændrer sig hver dag, men et postnummer kan gå måneder uden ændringer, så en webhook kun på postnummer vil ofte være stille længe.
5. Når webhooken er slået fra
Der er ikke et endpoint til at genaktivere en webhook. Gør sådan:
- Find og ret fejlen i dit endpoint. Tjek dine logs for 4xx og 5xx og for svar, der tog mere end 6 sekunder.
- Slet den gamle webhook med
DELETE /api/v1/webhooks/{id}. - Opret en ny med
POST /api/v1/webhooksog gem den nye hemmelighed. Se Webhooks: opsætning. - Kør
synk.pyén gang for at hente alt, hvad der skete, mens webhooken var slået fra.
Tjekliste
- Signaturen verificeres på hver levering (Verificér webhook-signaturen).
- Modtageren svarer 2xx på under et sekund og gemmer leveringen.
- Leveringer deduplikeres på
(entity, sekvensnummer). - Workeren henter hændelser fra din cursor, ikke kun id'erne i
changes. - Synkroniseringen kører også fra cron som sikkerhedsnet.
is_activeogfailure_countovervåges dagligt.- Webhook-URL'en svarer direkte uden redirect.
Relaterede guides
Markdown-kilde Fejl i guiden? Skriv til os API-reference (Swagger)