En Node.js-klient med fetch og @danadresse/client

Skriv en Node-klient med fetch, timeouts, fejlkoder og Retry-After, eller brug npm-pakken @danadresse/client med et retry-lag.

5 min Niveau: Øvet ✓ Kode testet

Node 18 og nyere har fetch og AbortSignal.timeout indbygget, så en klient til Danadresse kræver ingen afhængigheder. Denne guide viser først et lille modul med fetch, der dækker autocomplete, opslag, datavask, reverse geokodning, fejlkoder og 429. Derefter viser den npm-pakken @danadresse/client og det, du selv skal lægge ovenpå.

Koden kører på serveren. Nøglen i DANADRESSE_API_KEY må ikke sendes til browseren. Skal browseren selv kalde API'et, så brug en domænelåst nøgle, se Browsernøgler og tilladte domæner.

Modulet med fetch

Gem filen som danadresse.mjs. Importerer du den, får du funktionerne. Kører du den direkte med node danadresse.mjs, laver den seks kald som demo:

JavaScript#fetch-module
// danadresse.mjs: en lille klient til Danadresse API med fetch (Node 18+)
import { realpathSync } from "node:fs";
import { pathToFileURL } from "node:url";

const BASE_URL = process.env.DANADRESSE_BASE_URL ?? "https://api.danadresse.dk";
const API_KEY = process.env.DANADRESSE_API_KEY;
const TIMEOUT_MS = 10_000;
const MAX_RETRIES = 2;

export class DanadresseError extends Error {
  constructor(status, code, message, retryAfter, details = null) {
    super(`HTTP ${status} ${code}: ${message}`);
    this.status = status;
    this.code = code; // stabil kode, fx "invalid_api_key" eller "rate_limit_per_minute"
    this.retryAfter = retryAfter; // sekunder, eller null
    this.details = details; // fx resets_at og upgrade_url ved quota_exceeded
  }
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function toError(res) {
  let body = {};
  try {
    body = await res.json();
  } catch {
    // ikke JSON (fx en proxy-fejlside)
  }
  // DAWA-stier: {type,title,details,code}   /api/v1: {error:{code,message}}
  const err = body?.error && typeof body.error === "object" ? body.error : body ?? {};
  const ra = res.headers.get("retry-after");
  return new DanadresseError(
    res.status,
    err.code ?? null,
    err.title ?? err.message ?? res.statusText,
    ra && /^\d+$/.test(ra) ? Number(ra) : null,
    err.details ?? null,
  );
}

export async function get(path, params = {}) {
  const url = new URL(path, BASE_URL);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));

  for (let attempt = 0; ; attempt++) {
    let res;
    try {
      res = await fetch(url, {
        headers: { "X-Api-Key": API_KEY, Accept: "application/json" },
        signal: AbortSignal.timeout(TIMEOUT_MS),
      });
    } catch (e) {
      // timeout (TimeoutError) eller netværksfejl (TypeError)
      if (attempt >= MAX_RETRIES) throw e;
      await sleep(1000 * 2 ** attempt);
      continue;
    }
    if (res.ok) return res.json();

    const err = await toError(res);
    // 429 med Retry-After (minutgrænse) og 503 kan prøves igen. quota_exceeded har ingen Retry-After.
    if ((res.status === 429 || res.status === 503) && err.retryAfter && attempt < MAX_RETRIES) {
      await sleep(Math.min(err.retryAfter, 30) * 1000);
      continue;
    }
    throw err;
  }
}

function fromDawa(d, ag) {
  const [lon, lat] = ag.adgangspunkt?.koordinater ?? [null, null];
  return {
    id: d.id,
    betegnelse: d.adressebetegnelse,
    postnr: ag.postnummer?.nr ?? null,
    kommunekode: ag.kommune?.kode ?? null,
    lon,
    lat,
  };
}

export const autocomplete = (q, perSide = 5) =>
  get("/autocomplete", { q, per_side: perSide });

export async function adresse(id) {
  const d = await get(`/adresser/${encodeURIComponent(id)}`);
  return fromDawa(d, d.adgangsadresse);
}

export async function vask(betegnelse) {
  const svar = await get("/datavask/adresser", { betegnelse });
  const bedste = svar.resultater?.[0];
  // aktueladresse = den gældende version (afviger, hvis den fundne er nedlagt)
  const a = bedste?.aktueladresse ?? bedste?.adresse ?? null;
  return {
    kategori: svar.kategori, // "A" | "B" | "C", kun på topniveau
    kandidater: svar.resultater?.length ?? 0,
    sikker: (svar.kategori === "A" || svar.kategori === "B") && a !== null,
    afstand: bedste?.vaskeresultat?.afstand ?? null, // 0 = præcis match
    id: a?.id ?? null,
    tekst: a ? `${a.vejnavn} ${a.husnr}, ${a.postnr} ${a.postnrnavn}` : null,
  };
}

export async function reverse(lon, lat) {
  const d = await get("/adgangsadresser/reverse", { x: lon, y: lat });
  return fromDawa(d, d);
}

// Demo: køres kun, når filen startes direkte (node danadresse.mjs)
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
  for (const f of await autocomplete("Rådhuspladsen 1, 4200", 3)) console.log("forslag:", f.forslagstekst);
  const a = await adresse("0a3f50ae-b4ee-32b8-e044-0003ba298018");
  console.log("opslag:", a.betegnelse, a.lon, a.lat);
  for (const tekst of ["Strandvejen 100, 2900 Hellerup", "Rentemestervej 8, 2. tv, 2400 København NV"]) {
    const v = await vask(tekst);
    console.log("datavask:", v.kategori, v.kandidater, v.sikker ? v.tekst : "lad brugeren vælge");
  }
  console.log("reverse:", (await reverse(12.5683, 55.6761)).betegnelse);
  try {
    await adresse("00000000-0000-0000-0000-000000000000");
  } catch (e) {
    if (!(e instanceof DanadresseError)) throw e;
    console.log("fejl:", e.status, e.code);
  }
}

Output:

Text#fetch-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

Hvad modulet gør

Timeout. AbortSignal.timeout(10_000) afbryder kaldet efter 10 sekunder. Uden den venter fetch i princippet uendeligt på en hængende forbindelse.

To fejlformater, én code. Fejl på DAWA-stierne har formen {type, title, details, code}. Fejl på /api/v1/* har {error: {code, message, details}}. Det gælder alle fejl, også 500. toError() læser begge og giver dig err.code, som er stabil, og err.details. Forgren på den, aldrig på teksten. Alle koder står i Fejlformatet.

429 og Retry-After. Minutgrænsen (rate_limit_per_minute) og grænsen pr. besøgende på browsernøgler (rate_limit_per_visitor) svarer med Retry-After, og modulet venter så længe og prøver igen. Retry-After er 1 til 60 sekunder, tiden til minutvinduet nulstilles. quota_exceeded betyder, at en gratiskonto har brugt sin månedskvote og bufferen, eller at en betalt konto har nået det budgetloft, den har sat. Det svar har ingen Retry-After, og modulet kaster fejlen med det samme: et nyt forsøg hjælper ikke før err.details.resets_at. Vis err.details.upgrade_url, eller alarmer dig selv. 503 upstream_unavailable kommer, når et offentligt register er midlertidigt nede, og har også Retry-After. Afviste kald tælles og faktureres ikke. Inden for bufferen (op til 2 400 kald) har svarene X-Quota-Grace: true.

Datavask. vask() returnerer kategorien fra topniveau (A, B eller C), antallet af kandidater, afstand og den bedste adresse. Kategorien findes kun på topniveau, og der er intet score-felt. A er en præcis match. B er præcis én sikker match med små stavefejl: gem den, med den officielle tekst. C er usikker: vis kandidaterne, og lad brugeren vælge. I demoen giver "Strandvejen 100, 2900 Hellerup" A med én kandidat, og "Rentemestervej 8, 2. tv, 2400 København NV" giver C med syv, fordi det nummer ikke har en 2. tv. betegnelse læser selv etage og dør.

Brug modulet i Express

Lad browseren kalde din egen server, så nøglen bliver på serveren. Ruten oversætter DanadresseError til et svar, din frontend kan bruge:

JavaScript#express-route
// server.mjs
import express from "express";
import { autocomplete, adresse, DanadresseError } from "./danadresse.mjs";

const app = express();

app.get("/api/adresse/forslag", async (req, res, next) => {
  const q = String(req.query.q ?? "").trim();
  if (q.length < 2) return res.json([]);
  try {
    const forslag = await autocomplete(q, 8);
    res.json(forslag.map((f) => ({ tekst: f.forslagstekst, id: f.data.id ?? null, type: f.type })));
  } catch (e) {
    next(e);
  }
});

app.get("/api/adresse/:id", async (req, res, next) => {
  try {
    res.json(await adresse(req.params.id));
  } catch (e) {
    next(e);
  }
});

app.use((err, req, res, next) => {
  if (err instanceof DanadresseError) {
    // 404 videre som 404. Alt andet er vores problem, ikke brugerens.
    const status = err.status === 404 ? 404 : 502;
    return res.status(status).json({ error: err.code ?? "upstream_error" });
  }
  next(err);
});

app.listen(3000);

For forslag af typen vejnavn er data.id tom, fordi brugeren ikke har valgt et husnummer endnu. Se Adresse eller adgangsadresse for forskellen på typerne.

npm-pakken @danadresse/client

Pakken er på npm (npm install @danadresse/client) og pakker de mest brugte endpoints ind:

MetodeKald
autocomplete(q, { type, fuzzy, perSide })GET /autocomplete
adresse(id)GET /adresser/{id}
adresser(params)GET /adresser
adgangsadresser(params)GET /adgangsadresser
reverse(x, y, srid)GET /adgangsadresser/reverse
datavask(betegnelse)GET /datavask/adresser?betegnelse=…
postnumre(params)GET /postnumre
enrichAll(id), enrichBbr(id), enrichEjendom(id)GET /api/v1/enrich/adresser/{id}/…

Konstruktøren tager apiKey, baseUrl og timeoutMs (standard 10 sekunder). Fejl kastes som DanadresseError med status og body, hvor body er svarteksten som streng.

Pakken prøver ikke igen af sig selv, og den giver dig ikke adgang til svar-headers, så du kan ikke læse Retry-After eller X-RateLimit-*. Fejlkroppen fra minutgrænsen har den samme ventetid i details.retry_after, så et lille retry-lag ovenpå kan bruge den:

JavaScript#package-retry
// adresser.mjs
import { DanadresseClient, DanadresseError } from "@danadresse/client";

const api = new DanadresseClient({
  apiKey: process.env.DANADRESSE_API_KEY,
  timeoutMs: 8000,
});

// Pakken giver ikke headers, men minutgrænsens fejlkrop har details.retry_after (sekunder)
function retryAfterOf(err) {
  try {
    const body = JSON.parse(err.body);
    return (body.details ?? body.error?.details)?.retry_after ?? null;
  } catch {
    return null;
  }
}

export async function withRetry(fn, retries = 2) {
  for (let attempt = 0; ; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (!(err instanceof DanadresseError) || attempt >= retries) throw err;
      // 429 uden retry_after er quota_exceeded: prøv aldrig igen
      const wait = err.status === 503 ? 10 : err.status === 429 ? retryAfterOf(err) : null;
      if (wait === null) throw err;
      await new Promise((r) => setTimeout(r, Math.min(wait, 30) * 1000));
    }
  }
}

const hits = await withRetry(() => api.autocomplete("Rådhuspladsen 1, 4200", { type: "adresse", perSide: 5 }));
const adr = await withRetry(() => api.adresse(hits[0].data.id));
const vask = await withRetry(() => api.datavask("Strandvejen 100, 2900 Hellerup"));
const naer = await withRetry(() => api.reverse(12.5683, 55.6761));
console.log(adr.adressebetegnelse, vask.kategori, naer.adressebetegnelse);

Et par ting at vide om pakken:

  • En timeout giver en AbortError fra fetch, ikke en DanadresseError. withRetry ovenfor prøver den ikke igen. Tilføj det, hvis du har brug for det.
  • enrich*-metoderne virker ikke med testnøgler (403 sandbox_scope) og ikke med browsernøgler (403 browser_key_scope).
  • Har du brug for rate-limit-headers, fx til at drosle et batchjob ned, så brug fetch-modulet ovenfor.

Næste skridt

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 →