Errors¶
Every error is JSON with one envelope — including unknown paths under /v1, which never
fall back to HTML. Branch on error.code (stable, machine-readable), show error.message
to humans:
{
"error": {
"code": "validation_error",
"message": "Unknown filter 'countyy'",
"detail": { "filterable_columns": ["category", "county", "..."], "operators": ["..."] },
"request_id": "d41f6f7c9c3a4e59a1b2c3d4e5f60718"
}
}
detail appears only when there is structured context to add: unknown filters carry
filterable_columns + operators, bad sort/fields carry columns, malformed
parameters (e.g. limit out of range) carry a list of {param, message} pairs, and 429s
carry retry_after_seconds.
request_id echoes the X-Request-ID response header (send your own well-formed
X-Request-ID and it is echoed back; otherwise one is generated). Include it when
reporting a problem — it is how we find your request in the logs.
Codes¶
| HTTP | code |
Meaning | What to do |
|---|---|---|---|
| 401 | unauthorized |
Missing, malformed, unknown, or revoked API key | Check the Authorization: Bearer rok_… header |
| 404 | not_found |
No such dataset/route, or the dataset has not been ingested yet | Check the id against /v1/datasets |
| 405 | method_not_allowed |
The path exists but not for that verb — the API is read-only, so everything is GET/HEAD |
Use GET; the Allow header lists what the path accepts |
| 422 | validation_error |
The request is malformed: unknown filter column or operator, bad sort/fields, uncoercible value, cursor mixed with offset/sort, limit out of 1–1000 |
Fix the query — the message names the offending parameter |
| 429 | rate_limited |
Per-minute rate limit or monthly quota exceeded | Wait Retry-After seconds and retry; see limits |
| 502 | upstream_error |
A dependency (e.g. the CKAN portal) failed | Retry later |
| 500 | internal_error |
Unexpected server error | Retry later; report if persistent |
Design notes worth relying on¶
- 422 instead of silent tolerance. A typo like
?countyy=CLUJis an error, not an unfiltered 200 — you will never silently receive the whole dataset because of a misspelled filter. - One envelope, including the errors we don't raise ourselves. A wrong method or an unknown path is produced by the framework, not by our handlers, and still arrives in the shape above — there is no second error format to special-case.
- Errors are cheap but not free — they pass through rate limiting like any request (rejected requests never consume monthly quota).
- 429 bodies mirror the header —
error.detail.retry_after_secondscarries the same value asRetry-After, for HTTP clients that only surface the JSON body. 304 Not Modifiedis not an error — it's the success path of conditional requests; it has no body at all, not even the envelope.
Handling pattern¶
response = client.get("/v1/transport/vehicle_registry", params=params)
if response.status_code >= 400:
error = response.json()["error"]
match error["code"]:
case "rate_limited":
retry_after = int(response.headers.get("Retry-After", "5"))
...
case "validation_error":
raise ValueError(error["message"]) # a bug in our query — don't retry
case _:
response.raise_for_status()
const res = await fetch(url, { headers });
if (!res.ok) {
const { error } = await res.json();
switch (error.code) {
case "rate_limited": {
const wait = Number(res.headers.get("Retry-After") ?? 5);
// back off and retry
break;
}
case "validation_error":
throw new Error(error.message); // a bug in our query — don't retry
default:
throw new Error(`${res.status}: ${error.message}`);
}
}