# 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.
>
> https://danadresse.dk/guides/graenser-og-429 · verified 2026-11-02
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](/#pricing). Vil du have et loft, så sæt et budgetloft under [Betaling](/dashboard/billing#budget): 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:

| 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](/guides/testnoegler-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:

```bash id=show-headers run=live expect="x-ratelimit-minute-remaining"
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 id=headers-example run=no
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.

> [!NOTE]
> Alle headerne i tabellen og `Retry-After` er synlige via CORS. JavaScript i en browser kan læse dem med `headers.get()`, også på et 429-svar.

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

```json id=quota-exceeded-body run=no
{
  "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 id=backoff-python run=live expect="Aarhus"
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+:

```js id=backoff-js run=live expect="Aarhus"
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](/guides/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](/dashboard/usage).

## Næste skridt

- [Fejlformatet](/guides/fejlformat) til de andre fejlkoder.
- [Overvåg forbruget](/guides/overvaag-forbrug)
- [Retries og backoff i produktion](/guides/retries-og-backoff)
