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 (for example case:write or document:read)
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, session not yet complete when fetching a report, or — with Prefer: strict-status — a rejected or unreadable document (see below)
429Too Many RequestsRate limit exceeded (200 requests per minute per tenant)
500Internal Server ErrorUnexpected server-side failure
502 / 503 / 504Bad Gateway / Unavailable / TimeoutWith 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.

OutcomeDefaultWith Prefer: strict-status
Document accepted (status: "done", success: true)200200
Wrong document for the slot (status: "rejected")200422
Document could not be read (failure_kind: "input", e.g. a locked PDF)200422
Extraction backend errored (failure_kind: "upstream")200502
Extraction backend unavailable (failure_kind: "unavailable")200503
Extraction backend timed out (failure_kind: "timeout")200504
Tool not on your plan (plan_blocked: true, tool invoke only)200403

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/invoke response bodies, always when success is false.
  • 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_codeMeaning
doc_type_requiredA document type (or classifier type) is required
doc_type_unknownThe document type does not exist for your tenant
doc_type_disabledThe document type exists but is disabled
doc_type_existsA document type with this id already exists (409 on create)
context_store_unavailableThe document-type store could not be reached; retry later
classifier_type_unknownNo enabled classifier for the requested type
input_unreadableThe input file is corrupt or not a supported format
unknown_toolThe requested tool id does not exist
tenant_requiredThe call needs a tenant context and the credential has none (403)
tool_not_in_planThe tool is not included in your plan
invalid_inputThe tool parameters are invalid
upstream_errorThe extraction backend errored
service_unavailableThe extraction backend is unavailable
timeoutThe 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.