Curva troubleshooting: every error code and its fix
Every Curva error in one place: status, type, the SDK exception it raises, the usual causes and the fix, plus surprises that are not errors at all.
Curva errors all share one shape: an HTTP status and a JSON body {"error": {"type": "...", "message": "..."}}, where type is a fixed word such as invalid_request and message names the problem. The Python and TypeScript SDKs turn each status into a CurvaError subclass, so you can catch one case at a time. This page lists every status with its type, the exception it raises, the usual causes and the fix. It ends with things that surprise people but are not errors at all: verbal mode, calibrated: false, abstains and cache misses.
The error envelope: {error: {type, message}} and x-request-id
Every error from the HTTP API looks the same:
{"error": {"type": "invalid_request", "message": "..."}}Branch on type, not on the message text. The message is for people: a 422 names the question or image that is wrong.
Every response, error or not, also carries an x-request-id header. The same id appears in the server's log line for the request. When a provider fails, its own words stay in the server log and are never sent to callers, so the request id is how you find them. You can send your own id (1 to 64 visible ASCII characters) to follow a request across services. The Python SDK exposes it as d.request_id, and the TypeScript SDK as requestId.
Status to exception: AuthError, InvalidRequestError, NotFoundError, RateLimitError, ModelError
Both SDKs raise a CurvaError with status, type and message for every failure. Catch a subclass when you care about one case:
| Status | `type` | Python and TypeScript class | Retried by the SDK |
|---|---|---|---|
| 0 | none | CurvaError (server unreachable) | TypeScript retries dropped connections |
| 400 | invalid_json | InvalidRequestError | no |
| 401 | unauthorized | AuthError | no |
| 403 | none listed | CurvaError | no |
| 404 | not_found | NotFoundError | no |
| 413 | too_large | CurvaError | no |
| 422 | invalid_request | InvalidRequestError | no |
| 429 | rate_limited | RateLimitError, with retry_after (Python) or retryAfter (TypeScript) | yes |
| 502 | model_error, model_unavailable | ModelError | yes |
| 504 | timeout | ModelError | yes |
The clients retry 429, 500, 502, 503 and 504 three times by default and honour Retry-After. TypeScript caps that wait at 30 s. You only see a RateLimitError or ModelError after the retries ran out.
from curva import CurvaError, RateLimitError, InvalidRequestError
try:
d = client.decide(state, QUESTIONS)
except RateLimitError as e:
wait(e.retry_after)
except InvalidRequestError as e:
log.error("bad question: %s", e.message) # a 422 names the question
except CurvaError as e:
log.error("%s %s", e.status, e.type)The diagram below is the short version of this page: find your status, follow it to the fix.
flowchart LR E["CurvaError"] --> S0["status 0"] --> F0["start the server, check CURVA_BASE_URL"] E --> R4["400, 413, 422"] --> F4["fix the request; read the message"] E --> A4["401, 403, 404"] --> FA["check the key, its project, the id"] E --> C5["429, 502, 504"] --> FC["wait for Retry-After, check the model id"]
Status 0: the server never answered
Status 0 is not an HTTP status. It means the client could not reach a server at all. Usual causes:
- No server is running. The TypeScript SDK never starts one: run
curva serveor the Docker image first. CURVA_BASE_URLpoints to the wrong place. The default ishttp://localhost:7777.- The server was started with an address your client can't reach.
In Python, curva.local() and the module-level curva.decide start a private server for you. With no provider key at all, curva.decide raises an error naming the variables to set, such as OPENROUTER_API_KEY.
Request errors: 400, 413, 422
These mean the request itself is wrong. Retrying without a change gives the same error.
**400 invalid_json.** The body is not JSON, or it has an unknown mode or question type. In no-code tools the usual cause is mapped text with quotes or newlines pasted into a JSON template. Build the body with a JSON helper or a code step.
**413 too_large.** The body is over the server's limit, 16 MB by default. Large images are the usual cause. Raise the limit with curva serve --max-body-mb, or send smaller images.
**422 invalid_request.** The request is JSON, but something in it is not allowed. The message names the question or image. Causes from the reference:
- too few or too many options, or more than 64 questions;
- state over 150,000 characters;
- bad
max_length,minormax; mode: logprobswith a text, number or integer question, or with a Choice of more than 20 options;- more than 8 images, or an invalid one;
- an unknown operator in
whenorrules, or a rule answer that isn't a valid label; - an unknown
configname; depends_onwith unknown keys, a cycle or more than 8 stages, orexplaintogether withdepends_on;privacy: stricton a named provider whose URL is not on this machine;- a model whose provider is not configured.
For feedback, 422 means the label isn't a valid answer: use the option key for a Choice, the level index for a Score, and true or false for a Noul.
Access errors: 401, 403, 404
**401 unauthorized.** The server has API keys and none was sent, or the key is unknown or revoked. Revoking takes effect immediately. One trap: a server started without keys stays open until it is restarted, so restart after creating the first key, and expect callers without a key to start failing then.
**403.** The key was made with --project and the request is for another project. Use a key for that project, or one without a project binding.
**404 not_found.** An unknown decision, question or route. Three causes catch people out:
- Feedback for a question answered by a rule gets 404. Rule answers are not model output, so they are not stored for calibration.
- Feedback for a question skipped by its
whengets 404. There is no answer to correct. - With a project-bound key, another project's decisions look like they don't exist.
Capacity and provider errors: 429, 502, 504
**429 rate_limited.** One of three limits was hit: the API key's requests per minute (default 600), the model provider's rate limit, or the server's daily budget from CURVA_DAILY_LIMIT. Every 429 carries Retry-After in seconds. For the daily budget, that is the time until 00:00 UTC. The SDKs wait and retry for you.
**502 model_error.** Every model in the chain failed after retries. Details stay in the server log, so look up the x-request-id there. A fallback chain (model as a list) moves to the next model on 429 or a server error, which is the fix for a flaky provider.
**502 model_unavailable.** The model doesn't exist, was retired, or isn't open to your provider account. Providers rename models often. Check the id and your access. Retrying won't help.
**504 timeout.** The request took longer than the 120 s deadline. Each provider call also times out after 60 s by default. For a provider that queues, raise CURVA_PROVIDER_<NAME>_TIMEOUT (1 to 600 s).
Not errors, but surprising: verbal mode, calibrated false, abstains, cache misses
Some behaviour looks like a bug and is working as designed.
| What you see | Why |
|---|---|
mode is verbal, not logprobs | auto tries logprobs first and falls back to verbal when the model doesn't return them, remembered per model id. Choices with over 20 options and extraction questions are always verbal |
calibrated: false after you sent feedback | A calibrator needs 30 labels for the same exact question in the project, and is kept only when it beats the raw probabilities on held-out labels. A well-calibrated model is left raw on purpose |
calibrated: false after a small edit | Calibrators are keyed by the question's type, wording and options. Rewording starts over |
No abstain field | It is present only when the question set min_confidence |
abstain: true on a correct answer | The confidence was below your threshold. Until the question is calibrated, the threshold is a guess about the model |
cached: false on a repeat | The cache key covers model, state, questions, config, project, privacy and think. Change any of them and it is a new decision. The cache lives in server memory, sized with --cache-size |
debiased: false | debias: false, debias: "auto" asked only the original order, or one of the two orders failed |
Next steps
The error table is in the HTTP API reference, and the SDK classes are in the Python SDK reference. For retry patterns in code, read Python LLM API error handling. For the raw HTTP contract, see LLM decisions over HTTP, and for what Curva is, what is Curva. Install with pip install curva-ai.