Errors¶
All error responses are JSON with a consistent shape. The five status codes you might see:
400 Bad Request¶
Request validation failed (zod error) or a precondition wasn't met
(e.g., parliament_number supplied without session_number on the
internal search API).
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"statusCode": 400,
"error": "Bad Request",
"message": "querystring.status: Invalid enum value. Expected 'live' | 'partial' | 'blocked' | 'none'"
}
The message field carries the zod path + reason and is safe to log /
surface to the developer (you).
401 Unauthorized¶
The bearer token is missing, malformed, expired, revoked, or for the
wrong environment (a cpd_test_… token sent to the live API).
Anonymous calls don't 401 — they fall back to the IP-based rate
limit. You'll only see 401 if you sent an Authorization header that
didn't validate.
Possible error strings:
"missing bearer token (Authorization: Bearer cpd_…)"— header present but doesn't match theBearer cpd_…shape."invalid or malformed api key"— token failed the checksum check (typo, truncation, or wrong-environment)."invalid or expired api key"— token's checksum was good but the key is unknown / revoked / expired / for a suspended account.
If you get a 401 unexpectedly, check that:
- Your token hasn't been revoked
on
/account/api-keys. - You're not sending a test-mode token to the live API or vice versa.
- The token's
expires_at(if you set one) hasn't passed.
403 Forbidden¶
You're authenticated but your tier doesn't authorize this endpoint.
code: "insufficient_tier" — your billing level (free / dev /
pro) is too low. Surfaces on the pro-tier search endpoints when
called by a free or dev-tier key.
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"code": "insufficient_tier",
"error": "Forbidden",
"message": "this endpoint requires a pro+ tier API key. Your key is on the free tier. Subscribe or upgrade at /account/billing.",
"required_tier": "pro",
"current_tier": "free"
}
To resolve: subscribe at
/account/billing.
Existing keys auto-promote within seconds of the Stripe webhook
landing — no need to mint new keys.
Anonymous callers see 401 (caught by requireApiKey), not 403 —
the tier gate only runs after authentication succeeds.
404 Not Found¶
The resource doesn't exist. For /politicians/:id, returned when the
UUID is malformed OR when no politician has that id.
429 Too Many Requests¶
You've exceeded your tier's hourly rate limit. The Retry-After
header tells you how many seconds until the bucket resets.
HTTP/1.1 429 Too Many Requests
Retry-After: 2387
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
{
"statusCode": 429,
"error": "Too Many Requests",
"message": "Rate limit exceeded, retry in 2387 seconds"
}
See Rate limiting for the per-tier limits and the recommended retry strategy.
503 Service Unavailable¶
Two distinct flavours, distinguished by the code field:
code: "search_overloaded" — the public-search TEI semaphore is
at capacity (max 2 concurrent embed requests + max 6 queued). This
fires on /search/speeches, /search/speeches/count, /search/facets
under burst load. Always paired with Retry-After:
HTTP/1.1 503 Service Unavailable
Retry-After: 5
Content-Type: application/json
{
"code": "search_overloaded",
"error": "Service Unavailable",
"message": "public search service is at capacity, retry shortly"
}
Respect Retry-After. The semaphore drains as in-flight requests
complete (typically <1s each); 5 seconds is usually enough for the
queue to clear. See Rate limiting
for the full semaphore design.
code: "embedding_service_unavailable" — the underlying TEI
service is down or returning errors. Less frequent than overload
(TEI has its own auto-restart layer); when it does fire, treat as
transient and retry with exponential backoff (start 1s, cap 30s).
No Retry-After header — the duration is unpredictable.
HTTP/1.1 503 Service Unavailable
Content-Type: application/json
{
"code": "embedding_service_unavailable",
"message": "embedding service did not return ok"
}
500 Internal Server Error¶
Unhandled exception in the API. Bug — please file at
GitHub Issues
with the reqId from the response headers (Fastify includes one on
every request) so we can correlate with server logs.
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
{
"statusCode": 500,
"error": "Internal Server Error",
"message": "Something went wrong"
}
The exception message is redacted from the response body for security (no stack traces, no SQL state). Server-side logs have the full context.
Error handling pattern¶
A robust client should branch on these five codes:
import time
import requests
def call_api(url, headers):
for attempt in range(3):
r = requests.get(url, headers=headers, timeout=10)
if r.status_code == 200:
return r.json()
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "60")))
continue
if r.status_code == 503:
time.sleep(min(2 ** attempt, 30)) # exponential backoff
continue
if r.status_code in (400, 401, 403, 404):
# Permanent — don't retry. 403 means upgrade your tier.
r.raise_for_status()
if r.status_code == 500:
# Transient bug; retry once, then give up
if attempt == 0:
time.sleep(2)
continue
r.raise_for_status()
raise RuntimeError(f"Exhausted retries for {url}")