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.

4 min Niveau: Avanceret ✓ Kode testet

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

RegelVærdi
HvornårNår vores synkronisering har hentet nye ændringer, omtrent én gang i timen. Én levering pr. ændret entitet
Timeout6 sekunder for hele leveringen
SuccesHTTP-status under 400
FejlStatus 400 eller højere, timeout eller forbindelsesfejl
Nye forsøgIngen. En fejlet levering sendes ikke igen
Fejltællerfailure_count stiger med 1 pr. fejl og nulstilles ved første succes
Slås fraEfter 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.

Python#receiver
# 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:

Python#worker
# 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?

  • changes har højst 1.000 id'er. count fortæller, om der var flere.
  • For vejstykke indeholder changes kun kommunekoden, ikke vejkoden, så du kan ikke se, hvilken vej der er ændret.
  • changes siger 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:

Shell#cron
# 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:

Python#monitor
# 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:

  1. Find og ret fejlen i dit endpoint. Tjek dine logs for 4xx og 5xx og for svar, der tog mere end 6 sekunder.
  2. Slet den gamle webhook med DELETE /api/v1/webhooks/{id}.
  3. Opret en ny med POST /api/v1/webhooks og gem den nye hemmelighed. Se Webhooks: opsætning.
  4. 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_active og failure_count overvåges dagligt.
  • Webhook-URL'en svarer direkte uden redirect.

Relaterede guides

Markdown-kilde Fejl i guiden? Skriv til os API-reference (Swagger)

Prøv det med en gratis nøgle

2 000 kald/måned på gratisplanen. Intet kreditkort.

Opret konto →