En Python-klient med requests og httpx
Byg en lille Python-klient med autocomplete, opslag, datavask, reverse geokodning, timeouts, fejlkoder og 429-håndtering.
Denne guide giver dig en komplet Python-klient på omkring 120 linjer, som du kan lægge ind i dit projekt. Den dækker de fire kald, de fleste backends har brug for: autocomplete, opslag på id, datavask og reverse geokodning. Den håndterer også timeouts, begge fejlformater og svar med status 429.
Der findes ikke en officiel Python-pakke på PyPI i øjeblikket. Koden her bruger kun requests (eller httpx til asynkron kode), så du har ingen ekstra afhængigheder at holde opdateret.
Konfiguration
Klienten læser nøglen fra DANADRESSE_API_KEY og base-URL'en fra DANADRESSE_BASE_URL, med https://api.danadresse.dk som standard. Så kan du bruge en testnøgle lokalt og en live-nøgle i produktion uden at ændre koden.
pip install requests export DANADRESSE_API_KEY="dawa_live_din_nøgle_her"
Brug en dawa_test_…-nøgle under udvikling. Den tæller ikke på din konto. Se Testnøgler og sandbox.
Hele klienten med requests
Gem filen som danadresse_client.py. Når du kører den direkte, laver den seks kald og skriver resultatet ud:
"""danadresse_client.py: en lille, komplet klient til Danadresse API (requests).""" from __future__ import annotations import os import time from dataclasses import dataclass from typing import Any import requests BASE_URL = os.environ.get("DANADRESSE_BASE_URL", "https://api.danadresse.dk") class DanadresseError(Exception): """Fejl fra API'et. `code` er den stabile kode, du kan forgrene på.""" def __init__(self, status: int, code: str | None, message: str, details: Any = None, retry_after: float | None = None): super().__init__(f"HTTP {status} {code}: {message}") self.status = status self.code = code self.message = message self.details = details # fx resets_at og upgrade_url ved quota_exceeded self.retry_after = retry_after @dataclass(frozen=True) class Adresse: id: str betegnelse: str postnr: str | None kommunekode: str | None lon: float | None lat: float | None @dataclass(frozen=True) class Vask: kategori: str # "A", "B" eller "C" (topniveau i svaret) adresse: Adresse | None # bedste kandidat i den officielle stavning afstand: int | None # vaskeresultat.afstand: 0 = præcis match kandidater: int # antal resultater @property def sikker(self) -> bool: """A = præcis match, B = én sikker match med små stavefejl. C: lad brugeren vælge.""" return self.kategori in ("A", "B") and self.adresse is not None def _fejl(r: requests.Response) -> DanadresseError: try: body = r.json() except ValueError: body = {} if not isinstance(body, dict): body = {} # DAWA-stier: {"type","title","details","code"} /api/v1: {"error":{"code","message"}} err = body["error"] if isinstance(body.get("error"), dict) else body ra = r.headers.get("Retry-After") return DanadresseError( r.status_code, err.get("code"), err.get("title") or err.get("message") or r.text[:200], err.get("details"), float(ra) if ra and ra.isdigit() else None, ) def _fra_dawa(d: dict, ag: dict) -> Adresse: lon, lat = (ag.get("adgangspunkt") or {}).get("koordinater") or (None, None) return Adresse( id=d["id"], betegnelse=d.get("adressebetegnelse") or "", postnr=(ag.get("postnummer") or {}).get("nr"), kommunekode=(ag.get("kommune") or {}).get("kode"), lon=lon, lat=lat, ) def _fra_vask(a: dict) -> Adresse: # Datavask-adressen har ingen koordinater eller kommunekode: hent /adresser/{id} til dem linje = f"{a['vejnavn']} {a['husnr']}" enhed = " ".join(filter(None, [f"{a['etage']}." if a.get("etage") else None, a.get("dør")])) if enhed: linje += ", " + enhed return Adresse( id=a["id"], betegnelse=f"{linje}, {a['postnr']} {a['postnrnavn']}", postnr=a.get("postnr"), kommunekode=None, lon=None, lat=None, ) class DanadresseClient: def __init__(self, api_key: str | None = None, base_url: str = BASE_URL, timeout: tuple[float, float] = (3.05, 10), max_retries: int = 2): self.base_url = base_url.rstrip("/") self.timeout = timeout # (connect, read) i sekunder self.max_retries = max_retries self.session = requests.Session() # genbruger TCP/TLS-forbindelser self.session.headers["X-Api-Key"] = api_key or os.environ["DANADRESSE_API_KEY"] def _get(self, path: str, **params: Any) -> Any: for forsoeg in range(self.max_retries + 1): try: r = self.session.get(self.base_url + path, params=params, timeout=self.timeout) except (requests.ConnectionError, requests.Timeout): if forsoeg == self.max_retries: raise time.sleep(2 ** forsoeg) continue if r.ok: return r.json() err = _fejl(r) # 429 med Retry-After (minutgrænse) og 503 (register nede) kan prøves igen. # quota_exceeded har ingen Retry-After: kvoten eller budgetloftet er nået. if r.status_code in (429, 503) and err.retry_after and forsoeg < self.max_retries: time.sleep(min(err.retry_after, 30)) continue raise err raise RuntimeError("unreachable") def autocomplete(self, q: str, per_side: int = 5) -> list[dict]: return self._get("/autocomplete", q=q, per_side=per_side) def adresse(self, adresse_id: str) -> Adresse: d = self._get(f"/adresser/{adresse_id}") return _fra_dawa(d, d.get("adgangsadresse") or {}) def vask(self, betegnelse: str) -> Vask: svar = self._get("/datavask/adresser", betegnelse=betegnelse) res = svar.get("resultater") or [] bedste = res[0] if res else {} # aktueladresse = den gældende version (afviger, hvis den fundne er nedlagt) a = bedste.get("aktueladresse") or bedste.get("adresse") return Vask( kategori=svar.get("kategori", "C"), adresse=_fra_vask(a) if a else None, afstand=(bedste.get("vaskeresultat") or {}).get("afstand"), kandidater=len(res), ) def reverse(self, lon: float, lat: float) -> Adresse: d = self._get("/adgangsadresser/reverse", x=lon, y=lat) return _fra_dawa(d, d) if __name__ == "__main__": api = DanadresseClient() for f in api.autocomplete("Rådhuspladsen 1, 4200", per_side=3): print("forslag:", f["forslagstekst"]) a = api.adresse("0a3f50ae-b4ee-32b8-e044-0003ba298018") print("opslag:", a.betegnelse, a.lon, a.lat) for tekst in ("Strandvejen 100, 2900 Hellerup", "Rentemestervej 8, 2. tv, 2400 København NV"): v = api.vask(tekst) print("datavask:", v.kategori, v.kandidater, v.adresse.betegnelse if v.sikker else "lad brugeren vælge") r = api.reverse(12.5683, 55.6761) print("reverse:", r.betegnelse) try: api.adresse("00000000-0000-0000-0000-000000000000") except DanadresseError as e: print("fejl:", e.status, e.code)
Output:
forslag: Rådhuspladsen 1, 4200 Slagelse forslag: Rådhuspladsen 1, st. tv, 4200 Slagelse forslag: Rådhuspladsen 1, st. th, 4200 Slagelse opslag: Rådhuspladsen 1, st. th, 4200 Slagelse 11.35623868 55.40464424 datavask: A 1 Strandvejen 100, 2900 Hellerup datavask: C 7 lad brugeren vælge reverse: Rådhuspladsen 3, 1550 København V fejl: 404 not_found
Sådan virker de enkelte dele
Én Session pr. proces. requests.Session genbruger forbindelsen, så kald nummer to slipper for et nyt TLS-håndtryk. Opret klienten én gang, fx ved opstart af din Django- eller Flask-app, og del den.
Timeouts. (3.05, 10) betyder 3 sekunder til at oprette forbindelsen og 10 sekunder til at læse svaret. Uden timeout kan en hængende forbindelse blokere en worker i minutter.
Datavask med A, B og C. /datavask/adresser returnerer én kategori på topniveau og en liste resultater, bedste kandidat først. Hvert resultat har adresse (den fundne adresse), aktueladresse (den gældende version, som afviger, hvis den fundne er nedlagt) og vaskeresultat med afstand (0 = præcis), forskelle pr. felt og parsetadresse. Der er intet score-felt, og kategorien findes kun på topniveau:
| Kategori | Betydning | Hvad klienten bør gøre |
|---|---|---|
| A | Præcis match (store/små bogstaver og tegnsætning ses der bort fra) | Gem adresse.id |
| B | Præcis én sikker match med små stavefejl | Gem id'et, og gem den officielle tekst i stedet for det, brugeren skrev |
| C | Usikker: flere kandidater eller større forskelle | Gem ikke automatisk. Vis kandidaterne, og lad brugeren vælge |
sikker i eksemplet er sand for A og B. "Strandvejen 100, 2900 Hellerup" giver A med én kandidat. "Rentemestervej 8, 2. tv, 2400 København NV" giver C med syv kandidater, fordi det nummer ikke har en 2. tv. Brug /datavask/adgangsadresser, hvis du kun har brug for bygningen. betegnelse læser også etage og dør (2. tv, st. th), husbogstaver med småt (12a) og stavemåder med aa/å. Har din formular allerede separate felter, kan du sende vejnavn, husnr, etage, dør og postnr i stedet. Datavask-adressen har ingen koordinater, så slå /adresser/{id} op, hvis du skal bruge dem.
Reverse geokodning. /adgangsadresser/reverse?x=<længde>&y=<bredde> finder nærmeste adgangsadresse. Bemærk rækkefølgen: x er længdegraden. Se Koordinater og srid, hvis dine koordinater er i UTM32 (srid=25832).
Fejl og 429
Fejl på DAWA-stierne (/adresser, /autocomplete …) ser sådan ud, mens /api/v1/* lægger de samme felter under error. Det gælder alle fejl, også 500. Teksten i title og message følger Accept-Language (dansk uden headeren):
{"type": "RateLimitExceededError", "title": "Too many requests — the limit is 360 per minute. Try again in 12 seconds.", "details": {"limit_per_minute": 360, "reset_at": 1793620860, "retry_after": 12}, "code": "rate_limit_per_minute"}
{"detail": {"error": {"code": "sandbox_scope", "message": "The sandbox/demo key is limited to basic address lookups. …"}}, "error": {"code": "sandbox_scope", "message": "The sandbox/demo key is limited to basic address lookups. …"}}_fejl() læser code og details fra begge former, så din kode kan forgrene på e.code og aldrig på teksten. De vigtigste koder:
| Status | code | Handling |
|---|---|---|
| 401 | missing_api_key, invalid_api_key | Konfigurationsfejl. Prøv ikke igen |
| 403 | key_blocked, sandbox_scope, ip_not_allowed | Ret nøglen eller planen. Prøv ikke igen |
| 404 | not_found | Id'et findes ikke. Marker adressen som ugyldig |
| 429 | rate_limit_per_minute | Vent Retry-After sekunder (1 til 60) og prøv igen |
| 429 | quota_exceeded | Gratisplan: månedskvoten og bufferen er brugt. Betalt plan: dit budgetloft er nået. Ingen Retry-After. Prøv aldrig igen: stop, alarmer og vis e.details["resets_at"] og e.details["upgrade_url"] |
| 500 | internal_error | En fejl hos os. Log den, og prøv igen senere |
| 503 | upstream_unavailable | Et offentligt register er nede. Vent Retry-After |
e.details["reason"] på quota_exceeded fortæller hvorfor: free_quota, budget_stop_at_quota eller budget_cap_dkk. En gratiskonto, der passerer sin månedskvote, får stadig svar op til 2 400 kald (20 % ekstra). De svar har X-Quota-Grace: true og X-Quota-Remaining, så log headeren, hvis du vil have en tidlig advarsel. Kald, der afvises med 429, tælles og faktureres ikke.
Klienten venter højst 30 sekunder ad gangen og giver op efter to nye forsøg. Det holder en web-request inden for en rimelig svartid. I et batchjob kan du hæve max_retries. Grænserne er beskrevet i Grænser, kvoter og 429.
Asynkron variant med httpx
Bruger du FastAPI, aiohttp eller et andet asyncio-framework, så brug httpx.AsyncClient (pip install httpx). Strukturen er den samme. Her laves tre opslag parallelt over én forbindelsespulje:
"""Asynkron variant med httpx: én delt AsyncClient, timeouts og Retry-After.""" from __future__ import annotations import asyncio import os from dataclasses import dataclass from typing import Any import httpx BASE_URL = os.environ.get("DANADRESSE_BASE_URL", "https://api.danadresse.dk") @dataclass(frozen=True) class Adresse: id: str betegnelse: str postnr: str | None lon: float | None lat: float | None class DanadresseError(Exception): def __init__(self, status: int, code: str | None, message: str): super().__init__(f"HTTP {status} {code}: {message}") self.status, self.code, self.message = status, code, message class AsyncDanadresse: def __init__(self, api_key: str | None = None, max_retries: int = 2): self.max_retries = max_retries self.client = httpx.AsyncClient( base_url=BASE_URL, headers={"X-Api-Key": api_key or os.environ["DANADRESSE_API_KEY"]}, timeout=httpx.Timeout(10.0, connect=3.0), limits=httpx.Limits(max_connections=10), ) async def __aenter__(self) -> "AsyncDanadresse": return self async def __aexit__(self, *exc: object) -> None: await self.client.aclose() async def get(self, path: str, **params: Any) -> Any: for forsoeg in range(self.max_retries + 1): try: r = await self.client.get(path, params=params) except httpx.TransportError: # timeout, DNS, afbrudt forbindelse if forsoeg == self.max_retries: raise await asyncio.sleep(2 ** forsoeg) continue if r.is_success: return r.json() try: body = r.json() except ValueError: body = {} err = body.get("error") if isinstance(body.get("error"), dict) else body ra = r.headers.get("Retry-After", "") if r.status_code in (429, 503) and ra.isdigit() and forsoeg < self.max_retries: await asyncio.sleep(min(int(ra), 30)) continue raise DanadresseError(r.status_code, err.get("code"), err.get("title") or err.get("message") or "") raise RuntimeError("unreachable") async def adresse(self, adresse_id: str) -> Adresse: d = await self.get(f"/adresser/{adresse_id}") ag = d["adgangsadresse"] lon, lat = ag["adgangspunkt"]["koordinater"] return Adresse(d["id"], d["adressebetegnelse"], ag["postnummer"]["nr"], lon, lat) async def main() -> None: ids = [ "0a3f50ae-b4ee-32b8-e044-0003ba298018", "762a9a1f-a5fb-4e47-b1b2-9fa94026bc80", "fec4202d-1015-425d-a3f5-5d21d03194b6", ] async with AsyncDanadresse() as api: # Tre opslag parallelt over den samme forbindelsespulje for a in await asyncio.gather(*(api.adresse(i) for i in ids)): print(a.postnr, a.betegnelse) asyncio.run(main())
max_connections=10 sætter loft over, hvor mange kald der kører samtidig. Det er vigtigt i batchjobs: sender du tusind kald på én gang, rammer du minutgrænsen og får 429 tilbage. I FastAPI opretter du AsyncDanadresse i lifespan og lukker den ved nedlukning.
Gem id'et, ikke teksten
Når brugeren har valgt et forslag fra autocomplete, så gem data.id sammen med ordren eller kunden. Med id'et kan du altid hente den aktuelle adresse med api.adresse(id), også hvis vejen skifter navn. Se Gem adresse-id'et.
Næste skridt
- Fejlformatet: DAWA-stier og /api/v1 har alle fejlkoder.
- Grænser, kvoter og 429 forklarer minutgrænse og månedskvote.
- Skriver du også Node? Se Node-klient med fetch.
Relaterede guides
Markdown-kilde Fejl i guiden? Skriv til os API-reference (Swagger)