Retries and backoff

See which errors to retry and which never to retry, and use exponential backoff with jitter in Python, Node and PHP.

4 min Level: Intermediate ✓ Code tested

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.

StatuscodeRetry?
429rate_limit_per_minuteYes, after Retry-After (seconds until the minute resets, 1-60)
429rate_limit_per_visitorYes, after Retry-After (seconds until the minute resets, 1-60). Browser keys only
429quota_exceededNo. 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
429rate_limit_exceededNo. The monthly limit is reached. See details.reset_at
503upstream_unavailableYes, 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
500internal_errorOnce at most. If it persists, write to us via support
400, 422invalid_query_parameter, validation_errorNo. Fix the request
401missing_api_key, invalid_api_keyNo. Fix the key
403every codeNo. E.g. key_blocked, origin_not_allowed, sandbox_scope
404route_not_found or unknown idNo

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#retry-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

JavaScript#retry-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#retry-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

Related guides

Markdown source Spotted an error? Tell us API-reference (Swagger)

Try it with a free key

2,000 calls/month on the free plan. No credit card.

Create account →