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

CodeMeaningCommon causes
400Bad RequestMissing required field, invalid value, session in wrong state
401UnauthorizedMissing X-Api-Key header, invalid key, revoked key
403ForbiddenKey lacks the required scope (read, write, or admin)
404Not FoundCase, document, execution, or agent not found
409ConflictAttempting to decide a case that is not in review status
413Payload Too LargeUploaded file exceeds the size limit for its type. The detail names the limit and a route that would accept it — see below
415Unsupported Media TypeFile MIME type not in the allowlist (JPEG, PNG, WebP, TIFF, BMP, GIF, PDF)
422Unprocessable EntityRequest body failed schema validation, or session not yet complete when fetching a report
429Too Many RequestsRate limit exceeded (200 requests per minute per tenant)
500Internal Server ErrorUnexpected 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}")