Skip to main content

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 on 422; maps each field to its messages.
  • meta.requestId — quote this when contacting support about a failure.

The codes

Statuserror.codeWhat to do
400bad_requestFix the request.
400scope_missingYou forgot the X-Scope header on a scoped request. Pick a scopeId from GET /scopes/v1 and retry — this is not a permission problem.
401unauthenticatedToken missing/expired — re-authenticate or refresh.
403forbiddenThe user lacks the capability. Don't retry; surface it.
404not_foundGone, or not visible in this scope. Check X-Scope before assuming deleted.
409conflictState/idempotency conflict. Re-read, then decide.
422validation_failedRead error.details and fix the fields.
429rate_limitedBack off and retry (see below).
500server_errorTransient — retry with backoff; if it persists, report the requestId.
403 vs 404

A 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 forbidden

If 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:

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

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.

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
Only retry safe operations

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