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.
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: trueogX-Quota-Remainingmed de kald, der er tilbage i bufferen. Når bufferen også er brugt, svarer API'et429 quota_exceededindtil 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_exceededindtil 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:
| Plan | Kald pr. minut |
|---|---|
| Hobby | 360 |
| Basis | 1 700 |
| Pro | 7 000 |
| Enterprise | ubegræ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:
| Header | Indhold |
|---|---|
X-RateLimit-Limit | Månedskvoten |
X-RateLimit-Remaining | Kald tilbage i denne måned |
X-RateLimit-Reset | Unix-tid (sekunder) for næste månedsskifte |
X-RateLimit-Minute-Limit | Minutgrænsen, eller unlimited på Enterprise |
X-RateLimit-Minute-Remaining | Kald tilbage i dette minut |
X-RateLimit-Minute-Reset | Unix-tid (sekunder), hvor minutvinduet nulstilles |
X-Quota-Grace | true, når en Hobby-konto er over kvoten og bliver besvaret fra bufferen |
X-Quota-Remaining | Kald 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:
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'
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
code | Hvornår | Retry-After | Hvad du gør |
|---|---|---|---|
rate_limit_per_minute | Minutgrænsen er nået | Sekunder til minuttet nulstilles (1-60) | Vent og prøv igen |
rate_limit_per_visitor | En besøgende har ramt grænsen for browsernøgler | Sekunder til minuttet nulstilles (1-60) | Vent og prøv igen. Debounce dit adressefelt |
quota_exceeded | Hobby-kontoens kvote og buffer er brugt, eller dit budgetloft er nået | ingen | Stop. Vent til den 1., opgradér eller ret budgetloftet |
rate_limit_exceeded | Generel kvotegrænse. Er ikke slået til på de nuværende planer | ingen | Behandl 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:
{
"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_dkkviser 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:
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+:
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=1000på/adressergiver 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/udtraekfrem for tusindvis af sidekald. - Fordel batchjobs. En import med tusindvis af kald skal holde sig under minutgrænsen. Læs
X-RateLimit-Minute-Remainingog hold pause, som funktionerne ovenfor gør.
Kontoens forbrug kan du følge under Forbrug.
Næste skridt
- Fejlformatet til de andre fejlkoder.
- Overvåg forbruget
- Retries og backoff i produktion
Relaterede guides
Markdown-kilde Fejl i guiden? Skriv til os API-reference (Swagger)