The error format: DAWA paths and /api/v1

Read error responses from both parts of the API with one helper, and react to the stable code instead of the error text.

5 min Level: Intermediate ✓ Code tested

The API has two error formats. The DAWA-compatible paths (/adresser, /autocomplete, /postnumre and the rest without /api/v1) answer in DAWA's RFC 7807 format, so existing DAWA clients can read the errors. Our own endpoints under /api/v1 use an error envelope. Both formats carry a code field you can build your error handling on.

DAWA paths: type, title, details and code

JSON#dawa-401
{
  "type": "UnauthorizedError",
  "title": "The provided API key is invalid or revoked.",
  "details": null,
  "code": "invalid_api_key"
}
  • type is DAWA's error type. It depends only on the status code.
  • title is text for humans. It may be reworded, so do not use it in if statements.
  • details is null, an object or a list. For validation errors it is a list of the parameters that were wrong.
  • code does not exist in DAWA. We added it so you can tell, for example, key_blocked from invalid_api_key without reading the text. A DAWA client normally ignores fields it does not know, so the addition does not break existing code.
Statustype
400QueryParameterFormatError
401UnauthorizedError
403ForbiddenError
404ResourceNotFoundError
429RateLimitExceededError
500InternalServerError
410GoneError
502BadGatewayError
503ServiceUnavailableError

A validation error on a DAWA path always has status 400 and code invalid_query_parameter. details shows which parameter and why:

JSON#dawa-400
{
  "type": "QueryParameterFormatError",
  "title": "Invalid query parameter",
  "details": [
    {
      "type": "less_than_equal",
      "loc": ["query", "per_side"],
      "msg": "Input should be less than or equal to 1000",
      "input": "5000",
      "ctx": {"le": 1000}
    }
  ],
  "code": "invalid_query_parameter"
}

/api/v1: the error envelope

Errors from /api/v1 have an error object with code, message and optionally details. When the error comes from the API's own checks (key, quota, access), the same object also sits under detail, so clients written for FastAPI's default format keep working:

JSON#v1-403
{
  "detail": {
    "error": {
      "code": "browser_key_scope",
      "message": "Domain-locked browser keys cover address lookups only. Use a server-side key for enrich, datavask/parse and stats."
    }
  },
  "error": {
    "code": "browser_key_scope",
    "message": "Domain-locked browser keys cover address lookups only. Use a server-side key for enrich, datavask/parse and stats."
  }
}

Validation errors on /api/v1 have status 422 and only the error object:

JSON#v1-422
{
  "error": {
    "code": "validation_error",
    "message": "Invalid input",
    "details": [
      {"type": "missing", "loc": ["query", "betegnelse"], "msg": "Field required", "input": null}
    ]
  }
}

Always read error.code. It is present in every /api/v1 error, while detail is only in some.

The format holds for every error

The format follows the path, wherever the error comes from:

  • 500 answers with type InternalServerError and code internal_error on the DAWA paths, and with {"error":{"code":"internal_error",…}} on /api/v1.
  • 403 with code forbidden comes from our abuse filter, before the call reaches the API. It also has the DAWA format (type ForbiddenError) on the DAWA paths and the error envelope on /api/v1.
  • 404 on a path that does not exist has code route_not_found in both formats.

Every error response from the API carries Access-Control-Allow-Origin: *, so JavaScript in a browser can read the status and body instead of getting a CORS error.

Only 502 and 504 fall outside. They come from the proxy in front of the API and have an HTML page as the body. Your code must cope with a response that is not JSON.

The codes

codeStatusMeaningWhat to do
missing_api_key401No key in header or URLSend X-Api-Key. See Error 401
invalid_api_key401The key does not exist or was deletedCheck spelling and environment variable
account_suspended403The account is suspendedWrite to us via support
key_blocked403The key is blocked as leakedCreate a new key or lock it to domains
origin_not_allowed403Browser key used from another domainAdd the domain or use a server key
browser_key_scope403Browser key on enrich/parse/statsCall from your server with a server key
sandbox_scope403Test key on enrich/parse/statsUse a live key
ip_not_allowed403IP-locked key used from another IPAdd the IP to the key
forbidden403The abuse filter has blocked your IPWrite to us via support
invalid_query_parameter400Wrong parameter on a DAWA path, e.g. an srid other than 4326 and 25832Read details[].loc and msg
invalid_kvh, invalid_kvhx400The key has the wrong length12 characters for kvh, 19 for kvhx
invalid_cirkel, invalid_polygon400Wrong geometrySee Error 400 and 404
area_too_large400cirkel or polygon covers too many addressesMake the area smaller, or add postnr or kommunekode
validation_error422Wrong input on /api/v1Read error.details
not_found404The id or code does not existCheck the id type
route_not_found404The path does not existCheck the URL
unknown_entity404Unknown replication entitydetails.supported lists the valid ones
quota_exceeded429Free monthly quota (grace included) or your budget cap is used upWait until the 1st, upgrade or change the budget cap. See details.reason
rate_limit_per_minute429Too many calls this minuteWait Retry-After seconds
rate_limit_per_visitor429One visitor has called too much via a browser keyWait Retry-After seconds
upstream_unavailable503A public register is not answeringRetry after Retry-After
upstream_busy503Short throttle on cold enrichmentsRetry after Retry-After
internal_error500Our faultRetry, and write to us via support if it persists

One helper for both formats

This function gives you status, code, message, details and retry_after, whether the error came from a DAWA path, from /api/v1 or from the proxy. The example looks up an id that does not exist:

Python#read-error-python
import os
import requests


def read_error(resp: requests.Response) -> dict:
    """Normalise an error response from api.danadresse.dk."""
    info = {
        "status": resp.status_code,
        "code": None,
        "message": resp.reason,
        "details": None,
        "retry_after": resp.headers.get("Retry-After"),
    }
    try:
        body = resp.json()
    except ValueError:
        info["message"] = resp.text[:200] or resp.reason  # e.g. HTML from a 502
        return info
    if not isinstance(body, dict):
        return info
    err = body.get("error")
    if isinstance(err, dict):  # /api/v1
        info.update(code=err.get("code"), message=err.get("message"),
                    details=err.get("details"))
    elif "type" in body or "title" in body:  # DAWA paths (RFC 7807)
        info.update(code=body.get("code") or body.get("type"),
                    message=body.get("title"), details=body.get("details"))
    return info


r = requests.get(
    "https://api.danadresse.dk/adresser/00000000-0000-0000-0000-000000000000",
    headers={"X-Api-Key": os.environ["DANADRESSE_API_KEY"]},
    timeout=10,
)
if not r.ok:
    err = read_error(r)
    print(err["status"], err["code"], "-", err["message"])

Output:

Text#read-error-output
404 not_found - Adresse 00000000-0000-0000-0000-000000000000 not found

The same function in JavaScript, for Node 18+ and browsers:

JavaScript#read-error-js
export async function readError(res) {
  const info = {
    status: res.status,
    code: null,
    message: res.statusText,
    details: null,
    retryAfter: res.headers.get("Retry-After"),
  };
  const text = await res.text();
  let body;
  try {
    body = JSON.parse(text);
  } catch {
    info.message = text.slice(0, 200) || res.statusText; // e.g. HTML from a 502
    return info;
  }
  if (body && typeof body.error === "object" && body.error !== null) {
    info.code = body.error.code ?? null;
    info.message = body.error.message ?? info.message;
    info.details = body.error.details ?? null;
  } else if (body && ("type" in body || "title" in body)) {
    info.code = body.code ?? body.type ?? null;
    info.message = body.title ?? info.message;
    info.details = body.details ?? null;
  }
  return info;
}

const res = await fetch(
  "https://api.danadresse.dk/adresser/00000000-0000-0000-0000-000000000000",
  { headers: { "X-Api-Key": process.env.DANADRESSE_API_KEY } },
);
if (!res.ok) {
  const err = await readError(res);
  console.log(err.status, err.code, "-", err.message);
}

Retry-After is one of the headers the API exposes through CORS, so retryAfter works in a browser too. See Limits, quotas and 429.

Build the logic on code

An example of how the codes split up in a client:

Python#branch-on-code
RETRY = {"rate_limit_per_minute", "rate_limit_per_visitor", "upstream_unavailable",
         "upstream_busy", "internal_error"}
FIX_CONFIG = {"missing_api_key", "invalid_api_key", "key_blocked", "origin_not_allowed",
              "browser_key_scope", "sandbox_scope", "ip_not_allowed", "account_suspended"}


def classify(err: dict) -> str:
    """Return 'retry', 'config', 'stop' or 'bug' for a normalised error response."""
    if err["code"] in RETRY or err["status"] in (502, 504):
        return "retry"
    if err["code"] in FIX_CONFIG:
        return "config"
    if err["code"] == "quota_exceeded":
        return "stop"
    return "bug"
  • retry: try again after a wait. See Limits, quotas and 429.
  • config: do not retry. The key or its settings must be fixed.
  • stop: stop calling until the quota resets.
  • bug: wrong parameters or ids in your code. Log the whole response.

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 →