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.
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
{
"type": "UnauthorizedError",
"title": "The provided API key is invalid or revoked.",
"details": null,
"code": "invalid_api_key"
}typeis DAWA's error type. It depends only on the status code.titleis text for humans. It may be reworded, so do not use it inifstatements.detailsisnull, an object or a list. For validation errors it is a list of the parameters that were wrong.codedoes not exist in DAWA. We added it so you can tell, for example,key_blockedfrominvalid_api_keywithout reading the text. A DAWA client normally ignores fields it does not know, so the addition does not break existing code.
| Status | type |
|---|---|
| 400 | QueryParameterFormatError |
| 401 | UnauthorizedError |
| 403 | ForbiddenError |
| 404 | ResourceNotFoundError |
| 429 | RateLimitExceededError |
| 500 | InternalServerError |
| 410 | GoneError |
| 502 | BadGatewayError |
| 503 | ServiceUnavailableError |
A validation error on a DAWA path always has status 400 and code invalid_query_parameter. details shows which parameter and why:
{
"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:
{
"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:
{
"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
typeInternalServerErrorandcodeinternal_erroron the DAWA paths, and with{"error":{"code":"internal_error",…}}on/api/v1. - 403 with
codeforbiddencomes from our abuse filter, before the call reaches the API. It also has the DAWA format (typeForbiddenError) on the DAWA paths and theerrorenvelope on/api/v1. - 404 on a path that does not exist has
coderoute_not_foundin 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
code | Status | Meaning | What to do |
|---|---|---|---|
missing_api_key | 401 | No key in header or URL | Send X-Api-Key. See Error 401 |
invalid_api_key | 401 | The key does not exist or was deleted | Check spelling and environment variable |
account_suspended | 403 | The account is suspended | Write to us via support |
key_blocked | 403 | The key is blocked as leaked | Create a new key or lock it to domains |
origin_not_allowed | 403 | Browser key used from another domain | Add the domain or use a server key |
browser_key_scope | 403 | Browser key on enrich/parse/stats | Call from your server with a server key |
sandbox_scope | 403 | Test key on enrich/parse/stats | Use a live key |
ip_not_allowed | 403 | IP-locked key used from another IP | Add the IP to the key |
forbidden | 403 | The abuse filter has blocked your IP | Write to us via support |
invalid_query_parameter | 400 | Wrong parameter on a DAWA path, e.g. an srid other than 4326 and 25832 | Read details[].loc and msg |
invalid_kvh, invalid_kvhx | 400 | The key has the wrong length | 12 characters for kvh, 19 for kvhx |
invalid_cirkel, invalid_polygon | 400 | Wrong geometry | See Error 400 and 404 |
area_too_large | 400 | cirkel or polygon covers too many addresses | Make the area smaller, or add postnr or kommunekode |
validation_error | 422 | Wrong input on /api/v1 | Read error.details |
not_found | 404 | The id or code does not exist | Check the id type |
route_not_found | 404 | The path does not exist | Check the URL |
unknown_entity | 404 | Unknown replication entity | details.supported lists the valid ones |
quota_exceeded | 429 | Free monthly quota (grace included) or your budget cap is used up | Wait until the 1st, upgrade or change the budget cap. See details.reason |
rate_limit_per_minute | 429 | Too many calls this minute | Wait Retry-After seconds |
rate_limit_per_visitor | 429 | One visitor has called too much via a browser key | Wait Retry-After seconds |
upstream_unavailable | 503 | A public register is not answering | Retry after Retry-After |
upstream_busy | 503 | Short throttle on cold enrichments | Retry after Retry-After |
internal_error | 500 | Our fault | Retry, 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:
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:
404 not_found - Adresse 00000000-0000-0000-0000-000000000000 not found
The same function in JavaScript, for Node 18+ and browsers:
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:
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)