Errors
Every error response has the same shape: a stable machine-readable
error.type, a human-readable message, and the request id you can quote to support.
{
"error": {
"type": "insufficient_credits",
"message": "insufficient credit balance — add credits to create sandboxes"
},
"request_id": "a2f1c0de-6b1a-4a0e-9c2f-2f4d0f0a1b23"
}
Branch on error.type, never on the message text — messages are wording and may change.
Every response, successful or not, also carries an X-Request-Id header; log it. You can
also set it yourself by sending X-Request-Id on the request, which makes correlating your
traces with ours straightforward.
Every error type
| Status | error.type | Cause | How to handle it |
|---|---|---|---|
| 400 | invalid_request | A field failed validation: unknown size, name longer than 100 characters, timeout_minutes outside 1–1440, empty or oversized command, timeout_ms outside 1,000–600,000, a non-string env value or an invalid variable name, additional_minutes out of range, or an Idempotency-Key over 255 characters. | Fix the request. The message lists every failing rule, joined by ;. Never retry unchanged. |
| 401 | unauthorized | No credentials — the Authorization header was missing or not a Bearer token. | Send Authorization: Bearer aas_sk_…. See authentication. |
| 401 | invalid_api_key | The key does not match any key on file, or it has been revoked (the message distinguishes the two). | Check for truncation or a stale secret in your deployment. If it was revoked, issue a new key. |
| 403 | account_suspended | The key is valid but the account is suspended. | Do not retry. Contact support. |
| 402 | insufficient_credits | On create: the balance is below the minimum required to start a sandbox (about five minutes of small runtime). On exec: the balance has reached zero. | Add credit in Dashboard → Billing, then retry. Watch balance_usd from GET /v1/account to catch this before it happens. |
| 404 | not_found | No sandbox or execution with that id for this account. Also returned for an unknown /v1 path. | Check the id. Ids from another account look identical to ids that never existed — both are 404 by design. |
| 409 | sandbox_not_running | You tried to exec in, or extend, a sandbox that is still provisioning, or one that has failed. A sandbox that is already deleted returns 404 not_found instead — it is gone, not merely unavailable. The message names the actual status. | A sandbox never returns to running. Create a new one. |
| 429 | rate_limit_exceeded | More than 120 requests a minute, or more than 40 sandbox creations a minute, on one account. | Back off. The response carries Retry-After plus RateLimit-Remaining and RateLimit-Reset. |
| 429 | quota_exceeded | You already have 20 sandboxes in provisioning or running — the concurrency limit. | Destroy a sandbox before creating another, or queue the work. Retrying without freeing one fails again. |
| 502 | execution_failed | The command could not be delivered to the machine or the connection dropped mid-command. The command may or may not have run. | Retry only if the command is idempotent. If it repeats, the machine is unhealthy — destroy the sandbox and create a new one. |
| 503 | provisioning_failed | The machine failed to come up or to finish its bootstrap. It has already been cleaned up and nothing is billed. | Retry after a short pause, ideally with a fresh Idempotency-Key. |
| 503 | capacity_unavailable | The platform is at its fleet ceiling and cannot start another machine right now. | Retry with exponential backoff. This one usually clears within minutes. |
| 500 | internal_error | An unhandled failure on our side. Details go to our logs, never to the response. | Retry with backoff. If it persists, contact support with the request_id. |
| 400 | write_failed | The file could not be written. Usually a path the sandbox user cannot write, or a full disk. The message carries what the system said. | Write inside /workspace, or to a path your own commands could create. Nothing was written — the size in a success response is always the size actually on disk. |
| 400 | read_failed | The file exists but could not be read — most often permissions. | Check the path with list first, and remember your code runs as an unprivileged user. |
| 400 | list_failed | The directory could not be listed. | Confirm the path is a directory you can read. A path that does not exist returns not_found instead. |
| 413 | file_too_large | The file is over the 8 MiB transfer limit, in either direction. | Have the sandbox fetch or upload it directly — it has outbound network access and no such cap. |
| 4xx | request_error | Generic fallback for a client error raised before any handler ran — most often malformed JSON in the request body (400), or a body over the 1 MB limit (413). | Check that the body is valid JSON, that Content-Type: application/json is set, and that it is under 1 MB. |
Two more types exist in the platform but are not reachable through the public API today:
invalid_size (a size check inside the orchestrator that the request validator already
rejects as invalid_request) and sandbox_error (the fallback code for an
internal sandbox failure). Treat them like invalid_request and internal_error
respectively if you ever see one.
The table above is the whole list. If you meet an error.type that is not on it, that
is a documentation bug and we would like to hear about it —
tell us and include the request_id.
Not an error: 202 on delete
DELETE /v1/sandboxes/{id} can answer 202 with a normal sandbox body and
status: "deleting". Teardown did not finish synchronously and is being retried in the
background. It is not an error body — there is no error field — and it needs no action.
Billing has already been settled at that point.
What to retry
| Category | Statuses | Strategy |
|---|---|---|
| Never retry | 400, 401, 403, 404, 409 | The request itself is wrong, or the resource is in a state that will not change. Fix the caller. |
| Retry after freeing something | 429 quota_exceeded, 402 | Destroy a sandbox, or add credit. Then retry. |
| Retry with backoff | 429 rate_limit_exceeded, 500, 502, 503 | Exponential backoff with jitter. Honour Retry-After when present. |
When you retry a create, send the same Idempotency-Key so a request that actually
succeeded before failing on the wire does not leave you paying for two machines.
import os
import random
import time
import requests
API = "https://sandbox-as-a-service.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['AAS_API_KEY']}"}
RETRYABLE = {429, 500, 502, 503}
FATAL_TYPES = {"account_suspended", "quota_exceeded", "insufficient_credits"}
class ApiError(RuntimeError):
def __init__(self, status, type_, message, request_id):
super().__init__(f"{status} {type_}: {message} (request_id={request_id})")
self.status, self.type, self.request_id = status, type_, request_id
def call(method, path, *, headers=None, attempts=5, **kwargs):
for attempt in range(attempts):
r = requests.request(method, f"{API}{path}",
headers={**HEADERS, **(headers or {})}, **kwargs)
if r.ok:
return r.json()
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
err = body.get("error", {})
etype = err.get("type", "unknown")
# Fatal regardless of status: retrying cannot make these succeed.
if etype in FATAL_TYPES or r.status_code not in RETRYABLE:
raise ApiError(r.status_code, etype, err.get("message", r.text), body.get("request_id"))
delay = float(r.headers.get("Retry-After", 0)) or min(2 ** attempt, 30)
time.sleep(delay + random.random())
raise ApiError(r.status_code, "retries_exhausted", "gave up after retries", body.get("request_id"))const API = "https://sandbox-as-a-service.com/v1";
const RETRYABLE = new Set([429, 500, 502, 503]);
const FATAL_TYPES = new Set(["account_suspended", "quota_exceeded", "insufficient_credits"]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function call(method, path, { body, headers = {}, attempts = 5 } = {}) {
let last;
for (let attempt = 0; attempt < attempts; attempt++) {
const res = await fetch(`${API}${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.AAS_API_KEY}`,
"Content-Type": "application/json",
...headers,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.ok) return res.json();
last = await res.json().catch(() => ({}));
const type = last?.error?.type ?? "unknown";
if (FATAL_TYPES.has(type) || !RETRYABLE.has(res.status)) {
const e = new Error(`${res.status} ${type}: ${last?.error?.message ?? ""}`);
e.status = res.status;
e.type = type;
e.requestId = last?.request_id;
throw e;
}
const retryAfter = Number(res.headers.get("Retry-After")) || Math.min(2 ** attempt, 30);
await sleep(retryAfter * 1000 + Math.random() * 1000);
}
throw new Error(`gave up after retries (request_id=${last?.request_id})`);
}