Error Handling
Every failed request comes back in the same shape, so you can write one
error handler and reuse it everywhere. This guide shows how to read errors,
branch on them correctly, and handle the three cases that need special care:
validation (422), rate limits (429), and safe retries.
The error shape
On any 4xx/5xx, the body carries an error object (and always meta):
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"details": { "email": ["The email field is required."] }
},
"meta": { "requestId": "3f1c…" }
}
error.code— a stable, machine-readable string. Branch on this.error.message— human-readable; may change wording. Don't match on it.error.details— present on422; maps each field to its messages.meta.requestId— quote this when contacting support about a failure.
The codes
| Status | error.code | What to do |
|---|---|---|
| 400 | bad_request | Fix the request. |
| 400 | scope_missing | You forgot the X-Scope header on a scoped request. Pick a scopeId from GET /scopes/v1 and retry — this is not a permission problem. |
| 401 | unauthenticated | Token missing/expired — re-authenticate or refresh. |
| 403 | forbidden | The user lacks the capability. Don't retry; surface it. |
| 404 | not_found | Gone, or not visible in this scope. Check X-Scope before assuming deleted. |
| 409 | conflict | State/idempotency conflict. Re-read, then decide. |
| 422 | validation_failed | Read error.details and fix the fields. |
| 429 | rate_limited | Back off and retry (see below). |
| 500 | server_error | Transient — retry with backoff; if it persists, report the requestId. |
403 vs 404A 404 on something you know exists is usually a scope/visibility issue, not
a deleted record. A 403 means it's visible but your role can't do this. See
Permissions → 403 vs 404.
scope_missing vs forbiddenIf you get 400 scope_missing, your credentials and roles are fine — the request
simply carried no X-Scope header, so there was no scope to evaluate permissions
against. Send the header and retry. A 403 forbidden with the header present is a
real permission denial.
One handler to rule them all
Read the envelope once, raise a typed error carrying code/details/requestId,
and let callers branch on code:
- Python
- JavaScript
import requests
class LexabitError(Exception):
def __init__(self, status, code, message, details, request_id):
super().__init__(f"[{status} {code}] {message}")
self.status, self.code = status, code
self.details, self.request_id = details, request_id
def call(method, url, **kwargs):
resp = requests.request(method, url, **kwargs)
body = resp.json()
if resp.ok:
return body["data"], body.get("meta", {})
err = body.get("error", {})
raise LexabitError(
resp.status_code, err.get("code"), err.get("message"),
err.get("details"), body.get("meta", {}).get("requestId"),
)
# Usage
try:
client, _ = call("POST", f"{BASE}/clients/v1", headers=H, json=payload)
except LexabitError as e:
if e.code == "validation_failed":
for field, msgs in (e.details or {}).items():
print(f"{field}: {', '.join(msgs)}")
elif e.code == "forbidden":
print("You don't have permission to do that.")
else:
print(f"Unexpected error (ref {e.request_id})")
raise
class LexabitError extends Error {
constructor(status, { code, message, details } = {}, requestId) {
super(`[${status} ${code}] ${message}`);
Object.assign(this, { status, code, details, requestId });
}
}
async function call(method, url, opts = {}) {
const resp = await fetch(url, { method, ...opts });
const body = await resp.json();
if (resp.ok) return { data: body.data, meta: body.meta };
throw new LexabitError(resp.status, body.error, body.meta?.requestId);
}
// Usage
try {
const { data: client } = await call("POST", `${BASE}/clients/v1`, {
headers: H, body: JSON.stringify(payload),
});
} catch (e) {
if (e.code === "validation_failed") {
for (const [field, msgs] of Object.entries(e.details ?? {})) {
console.warn(`${field}: ${msgs.join(", ")}`);
}
} else if (e.code === "forbidden") {
console.warn("You don't have permission to do that.");
} else {
console.error(`Unexpected error (ref ${e.requestId})`);
throw e;
}
}
Handling validation (422)
error.details is keyed by field name, each mapping to a list of messages —
attach them directly to your form fields:
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"details": {
"displayName": ["The display name field is required."],
"countryCode": ["The country code must be 2 characters."]
}
},
"meta": { "requestId": "…" }
}
Backing off on 429 and 500
Rate limits and transient server errors are the cases worth retrying — with
exponential backoff, not a tight loop. Honor a Retry-After header if present.
- Python
- JavaScript
import time, requests
def call_with_retry(method, url, *, max_attempts=4, **kwargs):
delay = 1.0
for attempt in range(1, max_attempts + 1):
resp = requests.request(method, url, **kwargs)
if resp.status_code not in (429, 500, 502, 503) or attempt == max_attempts:
return resp
wait = float(resp.headers.get("Retry-After", delay))
time.sleep(wait)
delay *= 2 # exponential backoff
async function callWithRetry(method, url, opts = {}, maxAttempts = 4) {
let delay = 1000;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const resp = await fetch(url, { method, ...opts });
if (![429, 500, 502, 503].includes(resp.status) || attempt === maxAttempts) {
return resp;
}
const wait = Number(resp.headers.get("Retry-After")) * 1000 || delay;
await new Promise((r) => setTimeout(r, wait));
delay *= 2; // exponential backoff
}
}
GET is always safe to retry. Retrying a POST can create duplicates — unless
you make it idempotent (next section). Never blind-retry a 422/403; the
result won't change.
Safe retries for POST with idempotency
To retry a create without risking a duplicate, send an Idempotency-Key
(see API Conventions). The
server returns the original result on a repeat with the same key, and 409
if you reuse a key with a different body:
curl -X POST "https://api.lexabit.com/clients/v1" \
-H "Authorization: Bearer $LEXABIT_TOKEN" \
-H "X-Scope: 42" \
-H "Idempotency-Key: 1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed" \
-H "Content-Type: application/json" \
-d '{ "type": "company", "displayName": "Acme AS", "countryCode": "NO" }'
Generate one key per logical operation (e.g. a UUID), reuse it across retries of that operation, and you can safely combine it with the backoff helper above.
Where to go next
- API Conventions — the envelope and status codes
- Permissions — why
401/403/404happen