Grænser, kvoter og 429

Forstå månedskvoten og minutgrænsen, læs X-RateLimit-headerne, og håndter hver 429-kode med en backoff, der respekterer Retry-After.

5 min Niveau: Øvet ✓ Kode testet

API'et har to slags grænser: en månedskvote og en grænse pr. minut. De bliver målt hver for sig, og de giver hver deres 429-svar. Denne guide viser, hvordan du læser dem i headerne, og hvordan din kode skal reagere.

To grænser

Månedskvoten er det antal kald, din plan indeholder pr. kalendermåned. Den nulstilles den 1. i måneden kl. 00:00 UTC.

  • På Hobby-planen er kvoten 2 000 kald. Alle kontoens live-nøgler deler den samme kvote. Når den er brugt, får du stadig svar op til 2 400 kald (en buffer på 20 %). De svar har headerne X-Quota-Grace: true og X-Quota-Remaining med de kald, der er tilbage i bufferen. Når bufferen også er brugt, svarer API'et 429 quota_exceeded indtil den 1. i næste måned.
  • På betalte planer bliver kald ud over kvoten ikke afvist. De bliver afregnet som merforbrug. Se priserne under Priser. Vil du have et loft, så sæt et budgetloft under Betaling: stop ved den inkluderede kvote, eller et maksimalt beløb for merforbrug i kr. Når loftet er nået, svarer API'et 429 quota_exceeded indtil den 1.

Kald, der bliver afvist med 429, tæller ikke med i forbruget og bliver ikke faktureret. De bliver heller ikke besvaret, så en klient, der prøver igen i en løkke, kommer ingen vegne.

Minutgrænsen beskytter mod spidser. Den er hård på alle planer undtagen Enterprise, som ingen minutgrænse har:

PlanKald pr. minut
Hobby360
Basis1 700
Pro7 000
Enterpriseubegrænset

Minutvinduet følger uret: det starter ved hvert helt minut og nulstilles ved det næste.

Tre særtilfælde:

  • Testnøgler måles pr. klient-IP med Hobby-planens grænser. Se Testnøgler og sandbox.
  • Browsernøgler (låst til domæner) har derudover en grænse pr. besøgende: 120 kald i minuttet fra samme IP-adresse.
  • MCP-kald (fra vores MCP-server) har deres egen månedskvote, adskilt fra API-kvoten.

Headerne

Hvert svar på et kald med nøgle har disse headere:

HeaderIndhold
X-RateLimit-LimitMånedskvoten
X-RateLimit-RemainingKald tilbage i denne måned
X-RateLimit-ResetUnix-tid (sekunder) for næste månedsskifte
X-RateLimit-Minute-LimitMinutgrænsen, eller unlimited på Enterprise
X-RateLimit-Minute-RemainingKald tilbage i dette minut
X-RateLimit-Minute-ResetUnix-tid (sekunder), hvor minutvinduet nulstilles
X-Quota-Gracetrue, når en Hobby-konto er over kvoten og bliver besvaret fra bufferen
X-Quota-RemainingKald tilbage i bufferen. Kun sammen med X-Quota-Grace

På Enterprise står der kun X-RateLimit-Minute-Limit: unlimited, og de to andre minut-headere mangler. Se dine egne tal:

Shell#show-headers
curl -s -D - -o /dev/null "https://api.danadresse.dk/postnumre/8000" \
  -H "X-Api-Key: $DANADRESSE_API_KEY" | tr 'A-Z' 'a-z' | grep '^x-ratelimit'
HTTP#headers-example
x-ratelimit-minute-limit: 360
x-ratelimit-minute-remaining: 41
x-ratelimit-minute-reset: 1792072860
x-ratelimit-limit: 2000
x-ratelimit-remaining: 1873
x-ratelimit-reset: 1796083200

Tallene er kun et eksempel.

De fire 429-koder

codeHvornårRetry-AfterHvad du gør
rate_limit_per_minuteMinutgrænsen er nåetSekunder til minuttet nulstilles (1-60)Vent og prøv igen
rate_limit_per_visitorEn besøgende har ramt grænsen for browsernøglerSekunder til minuttet nulstilles (1-60)Vent og prøv igen. Debounce dit adressefelt
quota_exceededHobby-kontoens kvote og buffer er brugt, eller dit budgetloft er nåetingenStop. Vent til den 1., opgradér eller ret budgetloftet
rate_limit_exceededGenerel kvotegrænse. Er ikke slået til på de nuværende planeringenBehandl som quota_exceeded

quota_exceeded har bevidst ingen Retry-After. Det ærlige svar ville være "om nogle uger", og HTTP-klienter, der automatisk respekterer Retry-After, ville hænge så længe. Brug i stedet details.resets_at (ISO-8601, UTC), details.reset_at (Unix-tid) eller X-RateLimit-Reset:

JSON#quota-exceeded-body
{
  "type": "RateLimitExceededError",
  "title": "Gratis-kvoten på … API-kald/måned er brugt op (inkl. bufferen til … kald). …",
  "details": {
    "limit": 2000,
    "used": 2400,
    "reset_at": 1796083200,
    "resets_at": "2026-12-01T00:00:00Z",
    "upgrade_url": "https://danadresse.dk/dashboard/billing",
    "docs_url": "https://danadresse.dk/docs",
    "reason": "free_quota",
    "grace_limit": 2400
  },
  "code": "quota_exceeded"
}

title er skrevet til mennesker, på dansk eller engelsk efter Accept-Language. Uden headeren er den på dansk. På /api/v1 står de samme felter under error. details.reason fortæller hvorfor:

  • free_quota: Hobby-kvoten og bufferen er brugt.
  • budget_stop_at_quota: dit budgetloft stopper ved den inkluderede kvote.
  • budget_cap_dkk: dit loft for merforbrug er nået. details.budget_cap_dkk viser beløbet.

Ved de to budgetårsager peger details.budget_url på siden, hvor du ændrer loftet. Vis title og details.upgrade_url til den, der ejer kontoen, og prøv ikke igen automatisk.

Et 429-svar for minutgrænsen har Retry-After og details.retry_after med antal sekunder til minuttet nulstilles, men ikke minut-headerne. Dem finder du på det forrige, vellykkede svar.

En backoff, der læser headerne

Funktionen her prøver igen ved 429 fra minutgrænsen, 502, 503 og 504. Ventetiden tages fra Retry-After, ellers fra X-RateLimit-Minute-Reset, ellers stiger den eksponentielt. Ved quota_exceeded stopper den med det samme. Den holder også igen af sig selv, når X-RateLimit-Minute-Remaining rammer 0:

Python#backoff-python
import os
import random
import time

import requests

API = "https://api.danadresse.dk"
session = requests.Session()
session.headers["X-Api-Key"] = os.environ["DANADRESSE_API_KEY"]


class QuotaExceeded(Exception):
    pass


def _code(resp: requests.Response) -> str | None:
    try:
        body = resp.json()
    except ValueError:
        return None
    if not isinstance(body, dict):
        return None
    err = body.get("error")
    return err.get("code") if isinstance(err, dict) else body.get("code")


def _wait(resp: requests.Response, attempt: int) -> float:
    retry_after = resp.headers.get("Retry-After", "")
    if retry_after.isdigit():
        return int(retry_after)
    reset = resp.headers.get("X-RateLimit-Minute-Reset", "")
    if reset.isdigit():
        return max(1, int(reset) - time.time())
    return min(60, 2 ** attempt) + random.random()


def get(path: str, params: dict | None = None, attempts: int = 5) -> requests.Response:
    for attempt in range(attempts):
        resp = session.get(API + path, params=params, timeout=15)
        if resp.status_code == 429:
            if _code(resp) in ("quota_exceeded", "rate_limit_exceeded"):
                reset = resp.headers.get("X-RateLimit-Reset", "?")
                raise QuotaExceeded(f"Månedskvoten er brugt. Nulstilles {reset}")
        elif resp.status_code not in (502, 503, 504):
            if resp.headers.get("X-RateLimit-Minute-Remaining") == "0":
                time.sleep(_wait(resp, attempt))  # hold igen før næste kald
            return resp
        time.sleep(_wait(resp, attempt))
    return resp


r = get("/postnumre/8000")
r.raise_for_status()
print(r.json()["navn"], "| tilbage i minuttet:", r.headers.get("X-RateLimit-Minute-Remaining"))

Samme logik i JavaScript til Node 18+:

JavaScript#backoff-js
const API = "https://api.danadresse.dk";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export class QuotaExceeded extends Error {}

async function errorCode(res) {
  try {
    const body = await res.clone().json();
    return body?.error?.code ?? body?.code ?? null;
  } catch {
    return null;
  }
}

function waitMs(res, attempt) {
  const retryAfter = Number(res.headers.get("Retry-After"));
  if (retryAfter > 0) return retryAfter * 1000;
  const reset = Number(res.headers.get("X-RateLimit-Minute-Reset"));
  if (reset > 0) return Math.max(1000, reset * 1000 - Date.now());
  return Math.min(60_000, 2 ** attempt * 1000) + Math.random() * 1000;
}

export async function get(path, { attempts = 5 } = {}) {
  let res;
  for (let attempt = 0; attempt < attempts; attempt++) {
    res = await fetch(API + path, {
      headers: { "X-Api-Key": process.env.DANADRESSE_API_KEY },
      signal: AbortSignal.timeout(15_000),
    });
    if (res.status === 429) {
      const code = await errorCode(res);
      if (code === "quota_exceeded" || code === "rate_limit_exceeded") {
        throw new QuotaExceeded(`Månedskvoten er brugt. Nulstilles ${res.headers.get("X-RateLimit-Reset")}`);
      }
    } else if (![502, 503, 504].includes(res.status)) {
      if (res.headers.get("X-RateLimit-Minute-Remaining") === "0") {
        await sleep(waitMs(res, attempt)); // hold igen før næste kald
      }
      return res;
    }
    await sleep(waitMs(res, attempt));
  }
  return res;
}

const res = await get("/postnumre/8000");
const postnummer = await res.json();
console.log(postnummer.navn, "| tilbage i minuttet:", res.headers.get("X-RateLimit-Minute-Remaining"));

Undgå 429 i første omgang

  • Debounce autocomplete. Send først et kald, når brugeren har holdt pause i 150-250 ms. Et adressefelt uden debounce bruger et kald pr. tastetryk.
  • Cache svar, der ikke ændrer sig. Postnumre, kommuner og en adresse slået op på id ændrer sig sjældent. Gem dem i timer eller dage.
  • Brug store sider. per_side=1000 på /adresser giver et kald i stedet for ti. Se Paginering.
  • Hent hele datasæt med replikering. Skal du bruge alle adresser i en kommune eller i hele landet, så brug /replikering/udtraek frem for tusindvis af sidekald.
  • Fordel batchjobs. En import med tusindvis af kald skal holde sig under minutgrænsen. Læs X-RateLimit-Minute-Remaining og hold pause, som funktionerne ovenfor gør.

Kontoens forbrug kan du følge under Forbrug.

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 →