Errors

A request failed and you want to know why, whether to retry it, and whether it cost anything. Every error comes with an HTTP status, a code that names the exact cause, the field at fault when there is one, and a request id. This page lists every error with its fix, gives you a retry loop to copy, and explains why failed requests are never charged.

At a glance

The error object

Every error has the same JSON body. This request sends a choice question with only one option, so it fails validation. You can run it with any key on Tier 1 or above: it is rejected before anything runs or is charged. (A Tier 0 key gets 402 insufficient_credit first.)

cURL
curl -i https://api.saanora.com/v1/decisions \
  -H "Authorization: Bearer $SAANORA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "clara-1",
    "state": "Customer asks why they were charged twice this month.",
    "questions": {
      "team": {
        "type": "choice",
        "instructions": "Which team should own this?",
        "criteria": { "billing": "Handles invoices, charges and refunds" }
      }
    }
  }'
Response: 422
x-request-id: req_5b0c2e8f9a1d4c7b3e6f8a20
content-type: application/json

{
  "error": {
    "type": "invalid_request_error",
    "code": "invalid_criteria",
    "message": "choice needs criteria with 2 to 255 options",
    "param": "questions.team.criteria"
  },
  "request_id": "req_5b0c2e8f9a1d4c7b3e6f8a20"
}
FieldTypeMeaning
error.typestringThe broad category. There is one type per status, listed in the table below. Useful for grouping errors in logs and dashboards.
error.codestringThe exact cause, such as invalid_criteria. Branch on this field in your code.
error.messagestringA sentence for people that says what went wrong and usually how to fix it. Show it in logs; do not parse it.
error.paramstring or nullThe field that caused the error, as a path: model, state, questions, mode, or a question field such as questions.team.criteria. null when no single field is at fault, for example on a rate limit.
request_idstringThe id of this request, starting with req_. The x-request-id response header carries the same value, on errors and on successful responses.

The same errors and body apply to POST /v1/decisions and POST /v1/systemone, except missing_model: /v1/systemone uses clara-1 when model is left out. GET /v1/models can return the 401 and 503 errors.

Status codes

The status tells you which kind of problem you have and whether a retry can help.

Statuserror.typeMeaningRetry?
400invalid_request_errorThe body is not valid JSON, a required field is missing, the model is not a decision model, or the model rejected the request (invalid_request).No. Fix the request.
401authentication_errorThe API key is missing, malformed, unknown or revoked.No. Fix the key.
402insufficient_creditYour organization's credit cannot cover the request. Nothing ran.No. Add credit first.
404not_found_errorThe model does not exist or is not open yet (model_not_found), or the path does not exist (not_found).No. Use a listed model and a documented path.
413invalid_request_errorThe request body is larger than 1 MB.No. Send a smaller record.
422invalid_request_errorThe JSON is valid, but the questions or the mode break a rule, or the model rejected the request.No. Fix the field named in param.
429rate_limit_errorYour organization went over a rate limit, or the project reached its monthly spend limit.Only rate_limit_exceeded, after Retry-After.
502api_errorThe model could not answer this time.Yes, with backoff.
503overloaded_errorClara is starting after a quiet period (model_warming_up), or a platform service was briefly unavailable.Yes: after Retry-After when it is sent, otherwise with backoff.

Error codes

Each table covers one kind of failure. When a cause involves a specific field, error.param points at it.

Request errors (400, 413, 422)

The request itself needs to change. Sending it again unchanged returns the same error.

codeStatusCauseFix
invalid_json400The body is not valid JSON, for example a trailing comma or an unescaped quote inside state.Build the body with a JSON encoder (json= in Python requests, JSON.stringify in JavaScript), not by joining strings.
missing_state400The body has no state field.Add state: the record to decide about, as a string or a JSON object.
missing_questions400questions is missing, empty, or not an object. An array is not accepted.Send an object of 1 or more questions, keyed by your own question ids.
missing_model400model is missing on POST /v1/decisions.Set model, for example clara-1. POST /v1/systemone uses clara-1 when model is left out.
wrong_endpoint400The model exists but is not a decision model, so the decision endpoints do not serve it.Call a decision model such as clara-1. GET /v1/models shows each model's kind.
request_too_large413The request body is over 1 MB.Send a shorter record. Clara reads up to 16K tokens of state and shortens longer records in the middle, so a bigger body adds nothing.
too_many_questions422The request has more questions than your tier allows: 32 on Tier 1, 64 from Tier 2.Split the questions across requests (each request bills the record again), or move up a tier: Tier 2 ($50 paid and 7 days since your first payment) allows 64.
invalid_question_type422A question is not an object, or its type is not noul, choice or score.Fix the question that param names, for example questions.refund.type.
invalid_criteria422A choice question does not have an object of 2 to 255 options, or a score question does not have an array of 2 to 255 levels.Give choice an object of option id to short description, and score an array of levels, lowest first. param names the question.
invalid_mode422mode, in the body or the ?mode= query parameter, is not fast or consistent.Leave mode out for fast, or send consistent.
invalid_request400 or 422The request passed the platform's checks, but the model rejected it, for example a noul question whose criteria is not an object. Nothing is charged.Check each question against the rules in the API reference. Sending it again unchanged returns the same error; if the cause is unclear, write to support with the request id.

Authentication errors (401)

The key is missing or not accepted. See Authentication for how to send it.

codeStatusCauseFix
missing_api_key401No key was sent, or the value does not look like a Saanora key (sk_sa_...). A common cause is an environment variable that is not set, so the header carries an empty or placeholder value instead of a key.Send Authorization: Bearer sk_sa_... (or X-API-Key: sk_sa_...) and check that the variable is set where your code runs.
invalid_api_key401The key is not recognized: it was revoked, its project was archived, or it was copied wrong. A revoked key stops working within 60 seconds.Create a key in Console > API keys and replace the old one everywhere it is used.

Credit and limit errors (402, 429)

The request is fine, but your organization or project cannot run it right now. Only rate_limit_exceeded clears by waiting, within a minute at most. See Billing and credit and Rate limits.

codeStatusCauseFix
insufficient_credit402Your organization's credit cannot cover the hold placed before the model runs, or the organization is still on Tier 0, which is sign-up only (the message then says API access starts at Tier 1). The hold covers the worst case, so a request can be refused even when its final cost would have fit. Nothing ran.Add credit in Console > Billing (top-ups from $5); the first top-up moves the organization to Tier 1 at once. Credit is shared by every project in the organization.
rate_limit_exceeded429Your organization went over its requests per minute or tokens per minute in the last 60 seconds. The limits are shared by every project, key and model in the organization.Wait the number of seconds in Retry-After, then retry. Spread traffic across the minute, and see Rate limits for how tiers raise the limits. A single request that needs more tokens than your whole per-minute limit, such as a long record in consistent mode with many questions, runs only once your window is empty and then fills it, so retries wait for other traffic to clear: send fewer questions per request or move up a tier for steady throughput.
project_spend_limit429The project reached the monthly spend limit set on it. The month is the UTC calendar month.Do not retry. Requests fail until the next UTC month, or until an owner or admin raises the project's limit in the console.

Model errors (404, 502, 503)

A 404 means the model id is wrong. A 502 means the model was called but did not return answers. A 503 model_warming_up means Clara is starting after a quiet period. None is charged, and the 502 and 503 are safe to retry.

codeStatusCauseFix
model_not_found404No model with that id is available to you. mark-1 returns this until it opens.Use an id exactly as GET /v1/models lists it, such as clara-1.
not_found404The path does not exist, for example /v1/decision. A known path with the wrong method, such as GET /v1/decisions, returns 405 with the same error body.Use POST /v1/decisions, POST /v1/systemone or GET /v1/models.
model_unavailable502The model was called but did not return answers. Never charged.Retry with exponential backoff and jitter.
model_warming_up503Clara was idle and consistent mode is still starting, about 50 seconds after fast mode answers. This request did not run and was not charged.Wait the number of seconds in Retry-After, then retry. Once Clara is running, requests answer in well under a second.

Service errors (503)

A platform service the request depends on was briefly unavailable. Nothing ran, so retrying with backoff is safe.

codeStatusCauseFix
auth_unavailable503The service that checks API keys was briefly unavailable.Retry with backoff. Nothing ran.
billing_unavailable503The service that holds credit was briefly unavailable.Retry with backoff. Nothing ran and nothing was charged.
catalog_unavailable503The model catalog was briefly unavailable.Retry with backoff. Nothing ran.

What you are never charged for

You pay only for requests that return 200. No error on this page is charged, including 502 model_unavailable and an invalid_request rejected by the model.

This is how it works. Before the model runs, the platform places a hold on your credit large enough for the worst case. If the request succeeds, the exact cost is deducted and reported in usage.cost_usd. If it fails, nothing is deducted. If the hold cannot be placed at all, you get 402 insufficient_credit and the model never runs.

So retrying an error response costs nothing extra: you pay once, for the attempt that returns answers. A client-side timeout is different, because the request can still finish and be billed; see Cold starts and timeouts.

Retry temporary errors

Only these failures are temporary. Retry them, and nothing else:

Do not retry anything else: 400, 401, 402, 404, 413, 422 and 429 project_spend_limit fail the same way until you change the request, the key, your credit or the limit. Stop after a fixed number of attempts (6 in the examples below, about 31 seconds of backoff at most, plus any Retry-After) and surface the error with its request_id.

A rate-limited response looks like this. Retry-After is a whole number of seconds; x-ratelimit-reset-requests says when a request slot frees up.

Response: 429
retry-after: 7
x-ratelimit-limit-requests: 300
x-ratelimit-remaining-requests: 0
x-ratelimit-reset-requests: 7s
x-request-id: req_9d41a7c03e2b6f58a1c4e7d2

{
  "error": {
    "type": "rate_limit_error",
    "code": "rate_limit_exceeded",
    "message": "300 requests per minute on tier 1; lifetime top-ups raise it",
    "param": null
  },
  "request_id": "req_9d41a7c03e2b6f58a1c4e7d2"
}

Both examples below wrap POST /v1/decisions in a decide function that retries as described, returns the parsed decision on success, and raises an error carrying status, code, param and the request id otherwise. Set SAANORA_API_KEY in your environment before running them. The Python version needs pip install requests. The TypeScript version needs Node 18 or later (for fetch and AbortSignal.timeout) and runs as an ES module, for example saved as decide.mts and run with npx tsx decide.mts.

import os
import random
import time

import requests

API_URL = "https://api.saanora.com"
RETRYABLE = {429, 502, 503}


class SaanoraError(Exception):
    """An error response from the API, with the fields you branch on."""

    def __init__(self, status, error, request_id):
        self.status = status
        self.code = error.get("code")
        self.param = error.get("param")
        self.request_id = request_id
        super().__init__(f"{status} {self.code}: {error.get('message')} (request {request_id})")


def backoff(attempt, base=1.0, cap=30.0):
    """Exponential backoff with full jitter: a random wait of up to 1 s, 2 s, 4 s, ... capped at 30 s."""
    return random.uniform(0, min(cap, base * 2 ** (attempt - 1)))


def decide(body, max_attempts=6):
    for attempt in range(1, max_attempts + 1):
        last = attempt == max_attempts
        try:
            r = requests.post(
                f"{API_URL}/v1/decisions",
                headers={"Authorization": f"Bearer {os.environ['SAANORA_API_KEY']}"},
                json=body,
                timeout=120,  # the first request after a quiet period can take a while
            )
        except (requests.ConnectionError, requests.Timeout):
            if last:
                raise
            time.sleep(backoff(attempt))  # no response arrived
            continue

        if r.ok:
            return r.json()

        try:
            payload = r.json()
        except ValueError:
            payload = {}
        error = payload.get("error") or {}
        request_id = payload.get("request_id") or r.headers.get("x-request-id")
        retryable = r.status_code in RETRYABLE and error.get("code") != "project_spend_limit"
        if not retryable or last:
            raise SaanoraError(r.status_code, error, request_id)

        retry_after = r.headers.get("Retry-After")
        if retry_after:
            time.sleep(float(retry_after) + random.uniform(0, 1))  # the server's wait, plus jitter
        else:
            time.sleep(backoff(attempt))


try:
    result = decide({
        "model": "clara-1",
        "state": "Order 4417 arrived with a cracked screen. The customer asks for a replacement.",
        "questions": {
            "damaged": {"type": "noul", "instructions": "Did the item arrive damaged?"},
        },
    })
    print(result["answers"]["damaged"]["noul"], result["usage"]["cost_usd"])
except SaanoraError as e:
    if e.code == "insufficient_credit":
        print("Out of credit: add some in Console > Billing")
    elif e.status in (400, 413, 422):
        print(f"Fix the request at {e.param}: {e}")
    raise

A decision changes nothing on your side, so repeating one is safe: a retried request returns a fresh decision about the same record.

Cold starts and timeouts

Clara scales down when idle, so after a quiet period it needs about a minute to start: the first request waits about 60–80 seconds and is then answered. Consistent mode follows about 50 seconds later; until then a consistent request gets 503 model_warming_up, never charged, with a Retry-After header saying how many seconds to wait. Retry after that wait, as the loop above does. Once it is running, Clara answers five questions about one record in about 0.1 s.

Do not shorten the timeout. If your client gives up before the response arrives, the request can still finish on the server and be billed, and you never see the answers. A short timeout turns cold starts into paid requests you have to send again.

Avoid errors before they happen

Debug a failed request

Log four things for every failure: the HTTP status, error.code, error.param and request_id. Together they tell you what failed, where, and which request it was.

If you cannot work out the cause, write to support@saanora.com with the request id and the full error body. For every request that reaches the model, including model_unavailable and invalid_request failures, the platform stores its metadata (time, organization, project, key, model, mode, status, number of questions, input tokens, latency, cost and request id), so support can find it from the id alone. Requests refused before the model runs are not stored, so for those also say when you sent the request.

Support cannot see your record. State and questions are processed in memory and never written to the database. If the problem depends on what you sent, include the record and questions in your message, with anything sensitive removed.