Error codes, retry strategies, and idempotency keys
Error Handling
The 9thSense API returns errors as JSON objects with a single detail field:
{"detail": "Human-readable description of the error"}
FastAPI validation errors (model parse failures) return a 422 with a detail array containing per-field messages.
HTTP status codes
| Code | Meaning | Common causes |
|---|---|---|
| 400 | Bad Request | Missing required field, invalid value, session in wrong state |
| 401 | Unauthorized | Missing X-Api-Key header, invalid key, revoked key |
| 403 | Forbidden | Key lacks the required scope (read, write, or admin) |
| 404 | Not Found | Case, document, execution, or agent not found |
| 409 | Conflict | Attempting to decide a case that is not in review status |
| 413 | Payload Too Large | Uploaded file exceeds the size limit for its type. The detail names the limit and a route that would accept it — see below |
| 415 | Unsupported Media Type | File MIME type not in the allowlist (JPEG, PNG, WebP, TIFF, BMP, GIF, PDF) |
| 422 | Unprocessable Entity | Request body failed schema validation, or session not yet complete when fetching a report |
| 429 | Too Many Requests | Rate limit exceeded (200 requests per minute per tenant) |
| 500 | Internal Server Error | Unexpected server-side failure |
401 examples
{"detail": "Missing X-Api-Key header"}
{"detail": "API key invalid"}
{"detail": "API key revoked"}
403 example
{"detail": "Insufficient permissions: requires 'write' scope"}
409 example
{"detail": "Session is not in review status (current: completed)"}
413 example
The detail is an object, not a string: it carries the limit, the actual size,
and the route that would accept the file. The SDKs surface it as a typed
FileTooLargeError / FileTooLargeException so you never parse this by hand.
{
"detail": {
"reason": "file_too_large",
"message": "File too large (26214400 bytes). Maximum is 20 MB for application/pdf.",
"limit_bytes": 20971520,
"actual_bytes": 26214400,
"mime_type": "application/pdf",
"resolution": {
"type": "use_s3_reference",
"route": "POST /v1/cases/{case_id}/documents/from-source",
"label": "Attach the object from your own S3 bucket instead"
}
}
}
resolution is absent when there is no alternative route — attaching an object
that is already too large by reference cannot be helped by switching to the
by-reference route.
415 example
{"detail": "Unsupported file type: video/mp4. Accepted: images and PDFs."}
429 example
{"detail": "Rate limit exceeded: 200 requests per minute"}
The 429 response also includes a Retry-After: 60 header.
Rate limits
The platform uses a per-tenant sliding window counter:
- Window: 60 seconds
- Limit: 200 requests per tenant per window
The counter is in-process and resets on server restart. Rate limiting only applies to authenticated requests (when auth_enabled is true).
When your application receives a 429, wait for the Retry-After interval before retrying.
Idempotency keys
For POST, PUT, and PATCH requests, you can attach an Idempotency-Key header containing any unique string (UUID recommended). If the platform sees the same key again within 24 hours, it returns the cached response without re-executing the operation:
curl -X POST https://api.9thsense.ai/v1/cases \
-H "X-Api-Key: $API_KEY" \
-H "Idempotency-Key: 7f3c2a8e-1b4d-4e9f-a3c2-5d6e7f8a9b0c" \
-H "Content-Type: application/json" \
-d '{"goal": "thailand_visa", "tenant_id": "acme-corp"}'
A replayed response carries the header X-Idempotency-Replayed: true.
Only successful responses (HTTP 2xx) are cached. A failed request with the same idempotency key will be retried normally.
GET, DELETE, and OPTIONS requests are always passed through — they do not participate in idempotency caching.
Idempotency keys are stored in the idempotency_keys table. A background worker purges expired keys (older than 24 hours) periodically.
Retry strategy
The recommended strategy for transient failures:
import time
import httpx
def call_with_retry(client, method, url, *, max_retries=3, **kwargs):
"""Retry on 429 and 5xx with exponential backoff."""
for attempt in range(max_retries):
resp = client.request(method, url, **kwargs)
if resp.status_code == 429:
retry_after = int(resp.headers.get("Retry-After", 60))
time.sleep(retry_after)
continue
if resp.status_code >= 500:
wait = 2 ** attempt # 1s, 2s, 4s
time.sleep(wait)
continue
return resp
resp.raise_for_status()
return resp
For idempotent mutations, always set the Idempotency-Key header before the first attempt and reuse the same key on every retry. This guarantees at-most-once execution even if the network drops the response:
import uuid
import httpx
idempotency_key = str(uuid.uuid4()) # generate once, reuse on retries
for attempt in range(3):
resp = client.post(
"/v1/cases",
headers={"Idempotency-Key": idempotency_key},
json={"goal": "thailand_visa", "tenant_id": "acme-corp"},
)
if resp.status_code < 500:
break
Error handling in Python
import httpx
try:
resp = client.post("/v1/cases", json=payload)
resp.raise_for_status()
case = resp.json()
except httpx.HTTPStatusError as exc:
body = exc.response.json()
detail = body.get("detail", "unknown error")
print(f"API error {exc.response.status_code}: {detail}")