Errors¶
The API uses standard HTTP status codes plus a JSON body:
| Field | Meaning |
|---|---|
detail | Human-readable message (safe to show or log) |
code | Stable machine-readable code (may be absent on generic errors) |
Status codes¶
| Status | Meaning | What to do |
|---|---|---|
400 | Bad request (invalid/missing fields) | Fix the request body |
401 | Unauthenticated | Missing/invalid/revoked API key — check Authorization |
402 | Payment required | Your balance is at €0, or too small for what you asked — top up. Freeing resources does not help |
403 | Forbidden | Read key on a mutating route, or quota exceeded — use a full key / free capacity |
404 | Not found | Resource doesn't exist or isn't yours |
409 | Conflict | Machine isn't running, or exec_id already used |
410 | Gone | Job existed and its output was reaped (gone) — do not re-run blindly. Or a retired route (endpoint_retired) — the detail says what replaced it |
429 | Rate limited | Back off and retry (see below) |
501 | Not implemented | Guest image too old for jobs — rebuild/update the agent |
503 | Unavailable | If the body has exec_id, the command may be running — poll it |
5xx | Server error | Transient — retry with backoff; if persistent, contact support |
A 403 with no JSON body did not come from us
Cloud is behind a WAF, and it refuses some default client user-agents before the request reaches the API. Tell the two apart by the body, not the status:
| Our 403 | The WAF's 403 | |
|---|---|---|
content-type | application/json | text/plain |
| Body | {"detail": "...", "code": "forbidden"} | error code: 1010 |
| Headers | — | server: cloudflare, cf-ray |
If you see the right-hand column, no API key or scope will fix it — set a real User-Agent (see Authentication). Python's stdlib default, Python-urllib/x.y, is the one people hit; both SDKs send their own and are unaffected.
code values¶
Verified against the live API, 2026-09-22. These are the complete set a public API caller can receive.
code | Status | Meaning |
|---|---|---|
validation_error | 400 | A field, query parameter or cursor was invalid. The message names it |
forbidden | 403 | The action is not allowed for this caller |
quota_exceeded | 403 | An account limit was reached. The message names which one and how much is in use. Retry succeeds once that resource is freed — unlike forbidden, so branch on code, not the status. See Quotas |
insufficient_balance | 402 | The prepaid balance cannot pay for this — create, start, a volume or a snapshot at €0. Top up; unlike quota_exceeded, freeing something will not help |
not_found | 404 | Unknown id, or not owned by you |
conflict | 409 | The resource is in the wrong state — e.g. exec on a machine that is not running, or deleting a machine that still has snapshots |
invalid_state_transition | 409 | The requested lifecycle change is not legal from the current state |
gone | 410 | The job existed, but its record and output have been reaped. Do not re-run it blindly — it already ran |
endpoint_retired | 410 | The route was removed on purpose; the detail names its replacement. POST /v1/machines/{id}/exec answers this since 2026-10-11 |
rate_limited | 429 | Per-account limit hit |
internal_error | 500 | Server error. Transient — retry with backoff |
host_agent_error | 502 / 503 | The control plane could not reach the host. Retryable |
no_host_available | 503 | No host could take the machine right now. Retryable |
Authentication failures carry no code at all
This is the one case where branching on code does not work, and it is worth knowing before you write the handler. Auth and scope refusals are produced by middleware that runs before the handler layer, and it emits detail only:
| What happened | Status | Exact body |
|---|---|---|
| Key missing, malformed or revoked | 401 | {"detail":"invalid API key"} |
| A read key on a mutating route | 403 | {"detail":"this API key is read-only; a full-scope key is required for this action"} |
So err.code is undefined/None for both. Branch on the status for these two, and on code for everything else. The SDKs already do this: a 401, or a 403 whose code is not quota_exceeded, becomes an AuthError.
⚠ Earlier versions of this page listed invalid_api_key and forbidden_scope as codes, and machine_not_running for a 409. None of those three are ever sent. If your error handling branches on them, it has never fired — use the table above.
Handling rate limits¶
On 429, wait and retry with exponential backoff. Mutating routes and exec have separate limits, so a heavy exec loop won't block creates and vice-versa.
How the SDKs surface these¶
Both SDKs map responses to typed exceptions, each carrying status, detail and code:
| Exception | Raised for |
|---|---|
AuthError | 401, and a 403 that is not a quota refusal |
QuotaExceededError | 403 with code: quota_exceeded |
NotFoundError | 404 |
RateLimitError | 429 |
APIError | everything else — and the base class of all four |
Catch QuotaExceededError before AuthError
Both arrive as 403 and they need opposite handling, so the SDKs split them deliberately: QuotaExceededError is not a subclass of AuthError. An auth failure never succeeds on retry; a quota refusal succeeds as soon as you free the resource it names.
Folding them together is what sends people off to rotate a perfectly good API key. Order your except / catch blocks accordingly — and if you only want one branch, catch APIError, which is the parent of all of them.
from sistemo import Sandbox, QuotaExceededError, AuthError
try:
sb = Sandbox()
except QuotaExceededError as e: # first — it is the narrower case
print("free something and retry:", e.detail)
except AuthError as e: # second
print("credential problem:", e.detail)
else:
with sb: # closes the machine on the way out
print(sb.run("uname -a").stdout)
See Python errors / JavaScript errors and Quotas.