# Retries and backoff

> See which errors to retry and which never to retry, and use exponential backoff with jitter in Python, Node and PHP.
>
> https://danadresse.dk/en/guides/retries-og-backoff · verified 2026-11-02
A call can fail because you hit a limit, because a public register behind the API is down for a moment, or because the network hiccups. Some of those errors clear by themselves, and then it makes sense to try again. Others never clear, and another attempt only spends your quota and your time.

This guide shows how to tell them apart and gives a retry function in three languages.

## What to retry, and what to leave alone

Error responses carry a machine-readable `code`. On the DAWA paths it sits at the top level of the response (`{"type": …, "title": …, "code": "rate_limit_per_minute"}`), on `/api/v1` under `error.code`. See [The error format](/en/guides/fejlformat).

| Status | `code` | Retry? |
|---|---|---|
| 429 | `rate_limit_per_minute` | Yes, after `Retry-After` (seconds until the minute resets, 1-60) |
| 429 | `rate_limit_per_visitor` | Yes, after `Retry-After` (seconds until the minute resets, 1-60). Browser keys only |
| 429 | `quota_exceeded` | No. The free plan's quota and grace buffer are used up, or your budget cap is reached (`details.reason`). `details.resets_at` says when it resets |
| 429 | `rate_limit_exceeded` | No. The monthly limit is reached. See `details.reset_at` |
| 503 | `upstream_unavailable` | Yes, after `Retry-After`. A public register, e.g. Datafordeleren, is not answering right now |
| 502, 504 | – | Yes, a few times |
| Network error, timeout | – | Yes, a few times |
| 500 | `internal_error` | Once at most. If it persists, write to us via [support](/en/support) |
| 400, 422 | `invalid_query_parameter`, `validation_error` | No. Fix the request |
| 401 | `missing_api_key`, `invalid_api_key` | No. Fix the key |
| 403 | every code | No. E.g. `key_blocked`, `origin_not_allowed`, `sandbox_scope` |
| 404 | `route_not_found` or unknown id | No |

`quota_exceeded` deliberately has no `Retry-After`. The honest wait is days or weeks, and a client that waits that long in a loop is worse than one that gives up.

Calls refused with `429` are not counted as usage and cost nothing. They are not served either, and a client that retries immediately just gets another `429`. Respect `Retry-After`.

Only retry GET calls and `POST /datavask/…`. They change nothing. If a call creates something, such as a bulk job or a webhook, check whether the first attempt succeeded before you send it again.

## Exponential backoff with jitter

Wait longer for each attempt: 0.5 s, 1 s, 2 s, 4 s. Pick a random number between 0 and that bound (so-called "full jitter"), so many clients that failed together do not retry in the same second. If the server sends `Retry-After`, wait at least that long. Cap both the wait and the number of attempts.

In an address field in the browser you should not retry. The user keeps typing, and the next keystroke makes a new call. Retries belong in background jobs and server code.

## Python

```python id=retry-python run=live expect="Rådhuspladsen"
import os
import random
import time

import requests

RETRY_STATUS = {429, 502, 503, 504}
NEVER_CODES = {"quota_exceeded", "rate_limit_exceeded"}


def error_code(r: requests.Response) -> str | None:
    try:
        body = r.json()
    except ValueError:
        return None
    return body.get("code") or (body.get("error") or {}).get("code")


def get_with_retry(session, url, params=None, attempts=5, base=0.5, cap=30.0):
    for n in range(attempts):
        last = n == attempts - 1
        try:
            r = session.get(url, params=params, timeout=10)
        except (requests.ConnectionError, requests.Timeout):
            if last:
                raise
            time.sleep(random.uniform(0, min(cap, base * 2 ** n)))
            continue
        if r.status_code not in RETRY_STATUS or error_code(r) in NEVER_CODES or last:
            r.raise_for_status()
            return r
        wait = random.uniform(0, min(cap, base * 2 ** n))
        retry_after = r.headers.get("Retry-After", "")
        if retry_after.isdigit():
            wait = max(wait, float(retry_after))
        time.sleep(min(wait, cap))
    raise RuntimeError("unreachable")


session = requests.Session()
session.headers["X-Api-Key"] = os.environ["DANADRESSE_API_KEY"]
r = get_with_retry(session, "https://api.danadresse.dk/autocomplete",
                   params={"q": "Rådhuspladsen 1", "per_side": 1})
print(r.json()[0]["forslagstekst"])
```

## Node

```js id=retry-node run=live expect="Rådhuspladsen"
const RETRY_STATUS = new Set([429, 502, 503, 504]);
const NEVER_CODES = new Set(["quota_exceeded", "rate_limit_exceeded"]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

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

export async function fetchWithRetry(url, init = {}, { attempts = 5, base = 500, cap = 30000 } = {}) {
  for (let n = 0; n < attempts; n++) {
    const last = n === attempts - 1;
    let res;
    try {
      res = await fetch(url, { ...init, signal: AbortSignal.timeout(10000) });
    } catch (err) {
      if (last) throw err;
      await sleep(Math.random() * Math.min(cap, base * 2 ** n));
      continue;
    }
    if (!RETRY_STATUS.has(res.status) || NEVER_CODES.has(await errorCode(res)) || last) {
      if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
      return res;
    }
    let wait = Math.random() * Math.min(cap, base * 2 ** n);
    const retryAfter = Number(res.headers.get("retry-after"));
    if (retryAfter > 0) wait = Math.max(wait, retryAfter * 1000);
    await sleep(Math.min(wait, cap));
  }
  throw new Error("unreachable");
}

const url = new URL("https://api.danadresse.dk/autocomplete");
url.search = new URLSearchParams({ q: "Rådhuspladsen 1", per_side: "1" });
const res = await fetchWithRetry(url, { headers: { "X-Api-Key": process.env.DANADRESSE_API_KEY } });
console.log((await res.json())[0].forslagstekst);
```

## PHP

```php id=retry-php run=syntax
<?php
function getWithRetry(string $url, string $apiKey, int $attempts = 5, float $base = 0.5, float $cap = 30.0): array
{
    for ($n = 0; $n < $attempts; $n++) {
        $last = $n === $attempts - 1;
        $retryAfter = null;
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 10,
            CURLOPT_HTTPHEADER => ["X-Api-Key: $apiKey"],
            CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$retryAfter) {
                if (stripos($line, 'Retry-After:') === 0) {
                    $retryAfter = (int) trim(substr($line, 12));
                }
                return strlen($line);
            },
        ]);
        $body = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $networkError = curl_errno($ch) !== 0;
        curl_close($ch);

        $jitter = mt_rand() / mt_getrandmax() * min($cap, $base * 2 ** $n);
        if ($networkError) {
            if ($last) {
                throw new RuntimeException('Network error');
            }
            usleep((int) ($jitter * 1e6));
            continue;
        }
        $data = json_decode($body, true) ?? [];
        $code = $data['code'] ?? ($data['error']['code'] ?? null);
        $retry = in_array($status, [429, 502, 503, 504], true)
            && !in_array($code, ['quota_exceeded', 'rate_limit_exceeded'], true);
        if (!$retry || $last) {
            if ($status >= 400) {
                throw new RuntimeException("HTTP $status: $body");
            }
            return $data;
        }
        $wait = max($jitter, (float) ($retryAfter ?? 0));
        usleep((int) (min($wait, $cap) * 1e6));
    }
    throw new RuntimeException('unreachable');
}

$data = getWithRetry(
    'https://api.danadresse.dk/autocomplete?q=' . rawurlencode('Rådhuspladsen 1') . '&per_side=1',
    getenv('DANADRESSE_API_KEY')
);
echo $data[0]['forslagstekst'], PHP_EOL;
```

## When it still fails

When every attempt is used, log the status, `code` and the time, and pass the error on to your own error handling. If you log the full URL, strip `api_key` if the key is in it.

If you see many `503 upstream_unavailable`, check the [status page](/status). They come from the enrichment endpoints under `/api/v1/enrich/`, which fetch data live from public registers. Address lookup, autocomplete and datavask answer from our own database and are not affected.

## Next steps

- The limits in detail: [Limits, quotas and 429](/en/guides/graenser-og-429).
- Avoid calls you do not need: [Caching address lookups](/en/guides/caching).
- Watch how close you are to the limit: [Monitor usage](/en/guides/overvaag-forbrug).
