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

Statuserror.typeCauseHow to handle it
400invalid_requestA 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.
401unauthorizedNo credentials — the Authorization header was missing or not a Bearer token.Send Authorization: Bearer aas_sk_…. See authentication.
401invalid_api_keyThe 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.
403account_suspendedThe key is valid but the account is suspended.Do not retry. Contact support.
402insufficient_creditsOn 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.
404not_foundNo 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.
409sandbox_not_runningYou 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.
429rate_limit_exceededMore 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.
429quota_exceededYou 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.
502execution_failedThe 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.
503provisioning_failedThe 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.
503capacity_unavailableThe platform is at its fleet ceiling and cannot start another machine right now.Retry with exponential backoff. This one usually clears within minutes.
500internal_errorAn 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.
400write_failedThe 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.
400read_failedThe 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.
400list_failedThe directory could not be listed.Confirm the path is a directory you can read. A path that does not exist returns not_found instead.
413file_too_largeThe 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.
4xxrequest_errorGeneric 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

CategoryStatusesStrategy
Never retry400, 401, 403, 404, 409The request itself is wrong, or the resource is in a state that will not change. Fix the caller.
Retry after freeing something429 quota_exceeded, 402Destroy a sandbox, or add credit. Then retry.
Retry with backoff429 rate_limit_exceeded, 500, 502, 503Exponential 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})`);
}