Skip to main content

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:

400 Bad Request
{
"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:

400 Bad Request
{
"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:

401 Unauthorized
{
"detail": "Authentication credentials were not provided."
}
403 Forbidden
{
"detail": "You do not have permission to perform this action."
}
404 Not Found
{
"detail": "Not found."
}
429 Too Many Requests
{
"detail": "Request was throttled. Expected available in 12 seconds."
}

HTTP status codes at a glance

HTTPMeaningRetryable?
400 Bad RequestValidation or domain error. Body tells you which.No — fix the request.
401 UnauthorizedMissing or invalid Authorization header.After fixing credentials.
403 ForbiddenAuthenticated, but not allowed for this operation.No — fix permissions.
404 Not FoundResource doesn't exist or your account can't see it.No.
405 Method Not AllowedWrong HTTP method on this path.No — use the right verb.
409 ConflictIdempotency-key payload mismatch, or resource state conflict.Sometimes — reconcile first.
429 Too Many RequestsRate limited. Honour Retry-After header.Yes — back off.
500 / 502 / 503 / 504Server-side failure.Yes — exponential backoff + jitter.

Parsing recipes

Because v1 has multiple body shapes, branch on the HTTP status first, then inspect the body:

Python (requests)
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
JavaScript (fetch)
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

HTTPRetryable?Strategy
400NoFix the request first.
401After token refreshGet a fresh token; retry.
403NoFix permissions / authorization.
404NoResource doesn't exist.
409SometimesReload state, reconcile, retry.
429YesHonour Retry-After. Exponential backoff if header missing.
500/502/503/504YesExponential 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 stable code.
  • ✅ Shape 2: {field: [...messages]} — DRF serializer validation.
  • ✅ Shape 3: {detail: "..."} — auth, permission, not-found, throttle.
  • ✅ Honour Retry-After on 429.
  • ✅ Retry 5xx with exponential backoff + jitter.
  • ✅ Tolerate unknown fields in error bodies; we may add more over time.