Menu
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
- Branch on
error.code. It names the exact cause;error.messageexplains it in words for people. - Retry only
429 rate_limit_exceeded,502and503. Wait forRetry-Afterwhen the response sends it, otherwise back off exponentially with jitter. - Every other error comes back the same way until you change the request, the key, your credit or your limits, so do not retry it.
- Failed requests are never charged, and they do not count toward your tokens per minute.
- Log
request_idwith every failure and include it when you write to support. - Use a client timeout of 120 seconds, and retry
503 model_warming_upafter itsRetry-After: the first request after a quiet period finds Clara starting up.
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 -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" }
}
}
}'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"
}| Field | Type | Meaning |
|---|---|---|
error.type | string | The broad category. There is one type per status, listed in the table below. Useful for grouping errors in logs and dashboards. |
error.code | string | The exact cause, such as invalid_criteria. Branch on this field in your code. |
error.message | string | A sentence for people that says what went wrong and usually how to fix it. Show it in logs; do not parse it. |
error.param | string or null | The 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_id | string | The 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.
| Status | error.type | Meaning | Retry? |
|---|---|---|---|
| 400 | invalid_request_error | The 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. |
| 401 | authentication_error | The API key is missing, malformed, unknown or revoked. | No. Fix the key. |
| 402 | insufficient_credit | Your organization's credit cannot cover the request. Nothing ran. | No. Add credit first. |
| 404 | not_found_error | The 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. |
| 413 | invalid_request_error | The request body is larger than 1 MB. | No. Send a smaller record. |
| 422 | invalid_request_error | The 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. |
| 429 | rate_limit_error | Your organization went over a rate limit, or the project reached its monthly spend limit. | Only rate_limit_exceeded, after Retry-After. |
| 502 | api_error | The model could not answer this time. | Yes, with backoff. |
| 503 | overloaded_error | Clara 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.
| code | Status | Cause | Fix |
|---|---|---|---|
invalid_json | 400 | The 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_state | 400 | The body has no state field. | Add state: the record to decide about, as a string or a JSON object. |
missing_questions | 400 | questions 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_model | 400 | model is missing on POST /v1/decisions. | Set model, for example clara-1. POST /v1/systemone uses clara-1 when model is left out. |
wrong_endpoint | 400 | The 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_large | 413 | The 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_questions | 422 | The 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_type | 422 | A 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_criteria | 422 | A 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_mode | 422 | mode, in the body or the ?mode= query parameter, is not fast or consistent. | Leave mode out for fast, or send consistent. |
invalid_request | 400 or 422 | The 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.
| code | Status | Cause | Fix |
|---|---|---|---|
missing_api_key | 401 | No 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_key | 401 | The 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.
| code | Status | Cause | Fix |
|---|---|---|---|
insufficient_credit | 402 | Your 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_exceeded | 429 | Your 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_limit | 429 | The 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.
| code | Status | Cause | Fix |
|---|---|---|---|
model_not_found | 404 | No 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_found | 404 | The 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_unavailable | 502 | The model was called but did not return answers. Never charged. | Retry with exponential backoff and jitter. |
model_warming_up | 503 | Clara 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.
| code | Status | Cause | Fix |
|---|---|---|---|
auth_unavailable | 503 | The service that checks API keys was briefly unavailable. | Retry with backoff. Nothing ran. |
billing_unavailable | 503 | The service that holds credit was briefly unavailable. | Retry with backoff. Nothing ran and nothing was charged. |
catalog_unavailable | 503 | The 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.
- Refused before the model runs (every code except
model_unavailable,model_warming_upandinvalid_request): nothing ran, nothing is charged. - Failed after the model was called (502
model_unavailable, 503model_warming_up,invalid_request): no answers came back, nothing is charged. - Tokens per minute: failed and refused requests do not count toward it. The limit counts billed tokens only, the same number as
usage.input_tokens.
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:
- 429
rate_limit_exceeded: wait the number of seconds in theRetry-Afterheader, then retry. Add up to a second of random jitter so your workers do not all come back at the same moment. - 503
model_warming_up: consistent mode is still starting after a quiet period. Wait the number of seconds inRetry-After, then retry; it is ready about 50 seconds after fast mode. - Other 502 and 503 errors: retry with exponential backoff and full jitter: wait a random time between 0 and 1 s, then up to 2 s, 4 s, 8 s, capped at 30 s.
- Network errors and timeouts where no response arrived: retry with the same backoff.
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.
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}")
raiseA 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.
- Set your client timeout to 120 seconds. Check every layer between your code and the API: the HTTP client, any proxy, and any serverless function with its own time limit.
- Once Clara is running, the platform waits up to 120 seconds for an answer. If none comes, you get
502 model_unavailable, which is never charged, and the retry loop above retries it. - The
latency_msfield in a successful response is the model time, and thesaanora-processing-msheader is the total time in the platform. If your client measured much more than that, the time went to the network or your own code.
Avoid errors before they happen
- Build request bodies with a JSON encoder, never by joining strings. This rules out
invalid_json. - Check questions in your own code before sending: a
typeofnoul,choiceorscore; 2 to 255 options forchoice; 2 to 255 levels forscore;criterialeft out or an object fornoul; no more questions than your tier allows (32 on Tier 1, 64 from Tier 2). - Read
x-ratelimit-remaining-requestsandx-ratelimit-remaining-tokenson each response and slow down before they reach 0. - Call
GET /v1/modelswhen your service starts to confirm the model ids you use are live. - Keep enough credit for the worst-case hold of your largest request. Credit never expires, so a buffer is never lost. See Billing and credit.
- Keep records under 16K tokens. Longer ones are not rejected, but they are shortened in the middle, which can remove the text a question depends on.
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.