Retries and backoff
See which errors to retry and which never to retry, and use exponential backoff with jitter in Python, Node and PHP.
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.
| 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 |
| 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
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
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 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. 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.
- Avoid calls you do not need: Caching address lookups.
- Watch how close you are to the limit: Monitor usage.
Related guides
Markdown source Spotted an error? Tell us API-reference (Swagger)