Error model
Error responses from /api/v1/ are always JSON, always carry the matching HTTP status code, and fall into one of three shapes depending on where the error came from.
:::note v2 will adopt RFC 7807
A future major version (/api/v2/) is planned to switch all error responses to RFC 7807 problem+json — a single uniform shape. Until then, /api/v1/ returns the three shapes documented below. Write your client to be tolerant: branch on HTTP status code first, then look inside the body.
:::
The three error shapes you'll see
Shape 1 — Domain errors with a machine-readable code
Hand-written errors from our view code carry a stable error.code you can branch on:
{
"error": {
"code": "INVALID_ID",
"message": "ID must be in format '{device_type}_{numeric_id}'."
}
}
code is a stable identifier (INVALID_ID, TIME_RANGE_EXCEEDED, etc.). message is a human-readable explanation safe to surface to your end users.
You see this shape on: hand-rolled validation in the view layer — e.g. the traffic-data search endpoint's time-range cap, record-ID format check.
Shape 2 — Validation errors from the request body
When the request body fails serializer validation, you get DRF's field-keyed format:
{
"time": {
"from_dt": ["This field is required."],
"to_dt": ["Datetime has wrong format."]
},
"device_types": ["Invalid device type 'xyz'."]
}
Each key is a field name; each value is an array of human-readable messages for that field.
For cross-field errors (rules that involve multiple fields together), DRF puts them under non_field_errors:
{
"non_field_errors": ["'from' must be before 'to'."]
}
You see this shape on: any endpoint that validates a JSON body with DRF serializers — most write endpoints, search queries, etc.
Shape 3 — Authentication / permission / not-found
Authentication failures, permission denials, and resource-not-found errors use DRF's default detail shape:
{
"detail": "Authentication credentials were not provided."
}
{
"detail": "You do not have permission to perform this action."
}
{
"detail": "Not found."
}
{
"detail": "Request was throttled. Expected available in 12 seconds."
}
HTTP status codes at a glance
| HTTP | Meaning | Retryable? |
|---|---|---|
| 400 Bad Request | Validation or domain error. Body tells you which. | No — fix the request. |
| 401 Unauthorized | Missing or invalid Authorization header. | After fixing credentials. |
| 403 Forbidden | Authenticated, but not allowed for this operation. | No — fix permissions. |
| 404 Not Found | Resource doesn't exist or your account can't see it. | No. |
| 405 Method Not Allowed | Wrong HTTP method on this path. | No — use the right verb. |
| 409 Conflict | Idempotency-key payload mismatch, or resource state conflict. | Sometimes — reconcile first. |
| 429 Too Many Requests | Rate limited. Honour Retry-After header. | Yes — back off. |
| 500 / 502 / 503 / 504 | Server-side failure. | Yes — exponential backoff + jitter. |
Parsing recipes
Because v1 has multiple body shapes, branch on the HTTP status first, then inspect the body:
import requests, time
r = requests.post("https://mytrafficdata.com/api/v1/...", ...)
if r.status_code == 429:
wait = int(r.headers.get("Retry-After", 1))
time.sleep(wait)
# ...retry
elif r.status_code == 400:
body = r.json()
if "error" in body and "code" in body["error"]:
# Shape 1 — domain error
print(f"{body['error']['code']}: {body['error']['message']}")
else:
# Shape 2 — field validation errors
for field, messages in body.items():
print(f"{field}: {', '.join(messages) if isinstance(messages, list) else messages}")
elif r.status_code in (401, 403, 404):
print(r.json().get("detail", "Unknown error"))
elif r.status_code >= 500:
# Retry with backoff
pass
const r = await fetch(url, {headers, method: "POST", body: JSON.stringify(payload)});
if (r.status === 429) {
const wait = parseInt(r.headers.get("Retry-After") ?? "1", 10);
await new Promise(resolve => setTimeout(resolve, wait * 1000));
// ...retry
} else if (r.status === 400) {
const body = await r.json();
if (body.error?.code) {
console.error(`${body.error.code}: ${body.error.message}`);
} else {
// Field-keyed validation errors
Object.entries(body).forEach(([field, msgs]) => {
const list = Array.isArray(msgs) ? msgs.join(", ") : msgs;
console.error(`${field}: ${list}`);
});
}
} else if ([401, 403, 404].includes(r.status)) {
const body = await r.json();
console.error(body.detail);
}
Retry guidance
| HTTP | Retryable? | Strategy |
|---|---|---|
| 400 | No | Fix the request first. |
| 401 | After token refresh | Get a fresh token; retry. |
| 403 | No | Fix permissions / authorization. |
| 404 | No | Resource doesn't exist. |
| 409 | Sometimes | Reload state, reconcile, retry. |
| 429 | Yes | Honour Retry-After. Exponential backoff if header missing. |
| 500/502/503/504 | Yes | Exponential backoff with jitter. Cap at ~5 attempts. |
See Best practices for a retry-loop recipe.
Summary checklist
- ✅ Three body shapes today; v2 will unify to RFC 7807 problem+json.
- ✅ Branch on HTTP status code first, body second.
- ✅ Shape 1:
{error: {code, message}}— domain errors with stablecode. - ✅ Shape 2:
{field: [...messages]}— DRF serializer validation. - ✅ Shape 3:
{detail: "..."}— auth, permission, not-found, throttle. - ✅ Honour
Retry-Afteron 429. - ✅ Retry 5xx with exponential backoff + jitter.
- ✅ Tolerate unknown fields in error bodies; we may add more over time.