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 (for example case:write or document:read) |
| 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, session not yet complete when fetching a report, or — with Prefer: strict-status — a rejected or unreadable document (see below) |
| 429 | Too Many Requests | Rate limit exceeded (200 requests per minute per tenant) |
| 500 | Internal Server Error | Unexpected server-side failure |
| 502 / 503 / 504 | Bad Gateway / Unavailable / Timeout | With Prefer: strict-status: the extraction backend errored, was unavailable, or timed out (see below) |
401 examples
{"detail": "Missing X-Api-Key header"}
{"detail": "API key invalid"}
{"detail": "API key expired"}
{"detail": "API key pending approval"}
403 example
{"detail": "Insufficient permissions: requires 'case: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.
Upload and tool outcomes (Prefer: strict-status)
By default, POST /v1/cases/{case_id}/documents,
POST /v1/cases/{case_id}/documents/{document_id}/confirm,
POST /v1/cases/{case_id}/documents/from-source and
POST /v1/tools/invoke answer 200 for every request they processed, and the
outcome is in the body: status (done / rejected / failed) on uploads,
success on tool invocations.
Send Prefer: strict-status to get the outcome in the HTTP status code as well.
The response then carries Preference-Applied: strict-status, and the body is
the same as without the header — a 422 still includes doc_id, error,
mismatch_info and progress.
| Outcome | Default | With Prefer: strict-status |
|---|---|---|
Document accepted (status: "done", success: true) | 200 | 200 |
Wrong document for the slot (status: "rejected") | 200 | 422 |
Document could not be read (failure_kind: "input", e.g. a locked PDF) | 200 | 422 |
Extraction backend errored (failure_kind: "upstream") | 200 | 502 |
Extraction backend unavailable (failure_kind: "unavailable") | 200 | 503 |
Extraction backend timed out (failure_kind: "timeout") | 200 | 504 |
Tool not on your plan (plan_blocked: true, tool invoke only) | 200 | 403 |
A 422 means the document is at fault — fix or replace it; retrying the same
file will not help. A 502/503/504 means our side failed — retry later with
backoff. Every strict-status answer is for a request the server did
process: the document row already exists (as failed), so each retry attaches
a new document. Do not put these routes behind a blind retry-on-5xx wrapper.
From the SDKs
Both SDKs opt in with one setting and turn these answers into typed errors that still carry the document. Neither retries them automatically.
from ninthsense import Client, DocumentRejectedError, ExtractionUnavailableError
client = Client(api_key=KEY, strict_status=True)
try:
doc = await client.cases.upload(case_id, "pan.jpg", label="aadhaar")
except DocumentRejectedError as e: # 422 — ask for a different document
print(e.document.error, e.document.mismatch_info)
except ExtractionUnavailableError as e: # 502/503/504 — retry later
print(e.status_code, e.document.failure_kind)
Client client = new Client.Builder().apiKey(key).strictStatus(true).build();
try {
DocumentInfo doc = client.cases().uploadFile(caseId, path, "aadhaar").join();
} catch (CompletionException ce) {
if (ce.getCause() instanceof DocumentRejectedException) { // 422
DocumentInfo doc = ((DocumentRejectedException) ce.getCause()).document();
} else if (ce.getCause() instanceof ExtractionUnavailableException) { // 502/503/504
// retry later
}
}
tools.invoke raises InvocationError / InvocationException with the status
code and the failed result.
curl -X POST "$BASE/v1/cases/$CASE_ID/documents" \
-H "X-Api-Key: $KEY" -H "Prefer: strict-status" \
-F file=@pan.jpg -F label=aadhaar
# HTTP/1.1 422 Unprocessable Entity
# Preference-Applied: strict-status
# {"doc_id": "…", "status": "rejected", "error": "…", "mismatch_info": {…}, "progress": {…}}
Error codes
Errors carry a stable, machine-readable error_code so you can branch on the
cause without parsing messages. It appears in two places:
POST /v1/tools/invokeresponse bodies, always whensuccessisfalse.- Core 4xx bodies of these routes only, next to
detail(which stays a plain string on them):POST /v1/pipeline/run,POST /v1/agents/build,POST /v1/agents/builder/{id}/action(add_context),POST /v1/document-types(409 duplicate), and the tenant-less 403 on/v1/tools/invoke:{"detail": "Document type 'x' is disabled — enable it to use it", "error_code": "doc_type_disabled"}.
Other core errors return detail only (and a FastAPI request-validation 422
has a list detail).
The HTTP status of a tool failure only changes under Prefer: strict-status
(see above), except tenant_required, which is always 403; by default
/v1/tools/invoke answers 200 and the code is in the body.
error_code | Meaning |
|---|---|
doc_type_required | A document type (or classifier type) is required |
doc_type_unknown | The document type does not exist for your tenant |
doc_type_disabled | The document type exists but is disabled |
doc_type_exists | A document type with this id already exists (409 on create) |
context_store_unavailable | The document-type store could not be reached; retry later |
classifier_type_unknown | No enabled classifier for the requested type |
input_unreadable | The input file is corrupt or not a supported format |
unknown_tool | The requested tool id does not exist |
tenant_required | The call needs a tenant context and the credential has none (403) |
tool_not_in_plan | The tool is not included in your plan |
invalid_input | The tool parameters are invalid |
upstream_error | The extraction backend errored |
service_unavailable | The extraction backend is unavailable |
timeout | The extraction backend timed out |
Unknown or disabled document types return 422 (doc_type_unknown /
doc_type_disabled) from POST /v1/pipeline/run, POST /v1/agents/build and
the builder's add-context route; no agent is created on failure.
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}")
Tools API v2
POST /v2/tools/invoke always uses meaningful HTTP status codes; there is no Prefer header to send. Failures use one envelope:
{
"error": {
"code": "doc_type_unknown",
"message": "unknown context_id: 'x' — add it to document_types",
"retryable": false
},
"meta": { "request_id": "8f0c2b1e-4d7a-4c55-9a3e-1b2c3d4e5f60" }
}
Retry only when error.retryable is true (502, 503, 504 and 429). An unexpected server error is internal_error (500, not retryable): contact support with the request_id. Any other 4xx not listed (for example 409 or 413) is returned with that status and code invalid_request. Every other error means the request or document must change. A tool saying no (for example label: "fraud" or valid: false) is a 200, with the verdict in data. analyze returns doc_type_mismatch (422) when the model is confident the document is a different document type available to you (for example an Aadhaar sent as pan_card); error.detected names what it saw. Send "include_doc_type": false in params to skip that check. Quote meta.request_id when contacting support. See the Tools overview for the full code table.