# 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.
>
> https://danadresse.dk/guides/node-klient · verified 2026-11-02
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](/guides/browser-noegler-domaener).

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

```js id=fetch-module run=live expect="Rådhuspladsen"
// 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 id=fetch-output run=no
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](/guides/fejlformat).

**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:

```js id=express-route run=syntax
// 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](/guides/adresse-vs-adgangsadresse) for forskellen på typerne.

## npm-pakken @danadresse/client

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

| Metode | Kald |
|---|---|
| `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:

```js id=package-retry run=syntax
// 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

- [Grænser, kvoter og 429](/guides/graenser-og-429) forklarer headers og grænser.
- [Next.js route handler som proxy](/guides/nextjs-proxy) viser samme mønster i Next.js.
- Samme klient i Python: [Python-klient med requests og httpx](/guides/python-klient).
