Skip to content
Guides · Agent cookbook

Retry Sandbox API Calls Safely With Idempotency Keys

Updated October 27, 2026 .md

Short answer: Send an Idempotency-Key header on POST /sandboxes and on fork, and reuse it on every retry. For 24 hours, the same key and body return the same sandbox instead of a second billable one. Retry 429, 5xx and network errors with backoff. Don't retry other 4xx errors, because the request itself has to change.

An agent loop fails in an awkward way. A create request times out, but the server may already have started the VM. If you resend without a key, you can end up with two running sandboxes and pay for both. An idempotency key lets you resend without that risk.

What the key covers#

Call Takes Idempotency-Key A replay returns
POST /sandboxes (create) Yes 202 with the same sandbox.id
POST /sandboxes/{id}/fork Yes, scoped to that source sandbox 202 with the same fork's id
Everything else (commands, files, stop, delete) No Not applicable

How the server treats a key:

  • Lifetime: 24 hours from first use.
  • Scope: your account and the endpoint. A key used on create is separate from the same key on a fork, and each source sandbox's forks have their own scope.
  • Body match: the server compares the JSON body with key order and whitespace ignored. If the same key comes with a different body, you get 409 idempotency_key_reused.
  • Still running: a replay that arrives while the first request is still being processed gets 409 idempotency_in_progress. Retry with the same key.
  • Failed first try: if the first request fails, the server releases the key, so a retry with the same key tries the create again.
  • Length: at most 255 characters (400 invalid_idempotency_key). A UUID or job-<id> works well.

The sbx CLI and both SDKs already send a generated key on create and fork. Pass your own key when the retry has to survive a process restart, for example a queue worker that crashes and picks up the same job again.

Which errors to retry#

Response Retry? How
Network error or timeout Only for a GET, or a create or fork sent with a key Backoff, same key
429 rate_limited, daily_limit_reached Yes Wait for Retry-After (also details.retryAfterSeconds, details.resetsAt)
429 limit_reached No, not in a loop Too many sandboxes are running at once. Stop one first
409 idempotency_in_progress, sandbox_starting, sandbox_restoring Yes Short backoff
503 out_of_capacity, no_ready_machine, capacity_exceeded; other 5xx Yes, a few times Backoff. For capacity_exceeded, a smaller size may fit now (details.availableBySize)
409 idempotency_key_reused No Use a new key for the new body
Other 4xx (400, 401, 402, 403, 404) No Fix the request, the key or the billing

This follows the retry rules in the limits docs: exponential backoff with jitter, starting near 500 ms and capped near 30 s. Log requestId with every failure.

A retry helper in TypeScript#

This helper uses Node's built-in fetch (Node 18 or later) and no dependencies:

// retry.mjs: a small retry helper for the Sandboxer REST API (Node 18+, built-in fetch)
const API = "https://sandboxer.com/api/v1";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const RETRY_CODES = new Set(["idempotency_in_progress", "sandbox_starting", "sandbox_restoring", "rate_limited"]);

function retryable(status, code) {
  if (status === 429) return code !== "limit_reached" && code !== "member_limit_reached";
  if (status >= 500) return true;          // 500, 502, 503 out_of_capacity / capacity_exceeded ...
  return RETRY_CODES.has(code);            // a few 409s; every other 4xx needs a changed request
}

export async function call(method, path, { body, idempotencyKey, maxAttempts = 5 } = {}) {
  const headers = { Authorization: `Bearer ${process.env.SANDBOXER_API_KEY}` };
  if (body !== undefined) headers["Content-Type"] = "application/json";
  if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
  for (let attempt = 1; ; attempt++) {
    let res, data;
    try {
      res = await fetch(API + path, { method, headers, body: body && JSON.stringify(body) });
      data = await res.json().catch(() => ({}));
    } catch (err) {
      // Network error or timeout: only safe to resend when a key makes the call idempotent (or it's a GET).
      if (attempt >= maxAttempts || !(idempotencyKey || method === "GET")) throw err;
      await sleep(backoff(attempt));
      continue;
    }
    if (res.ok) return { status: res.status, attempt, data };
    const code = data.code;
    if (attempt >= maxAttempts || !retryable(res.status, code)) {
      const e = new Error(`${res.status} ${code}: ${data.message}`);
      Object.assign(e, { status: res.status, code, requestId: data.requestId, attempt });
      throw e;
    }
    const wait = Number(res.headers.get("retry-after")) * 1000 || (data.error?.details?.retryAfterSeconds ?? 0) * 1000;
    await sleep(Math.min(wait || backoff(attempt), 60_000));
  }
}

// Exponential backoff with full jitter: 0.5 s, 1 s, 2 s ... capped at 30 s.
const backoff = (attempt) => Math.random() * Math.min(30_000, 500 * 2 ** (attempt - 1));

We ran it on production. The script sends the same create twice with one key, then the same key with a different body, then a request for a sandbox that doesn't exist:

import { randomUUID } from "node:crypto";
import { call } from "./retry.mjs";

const key = randomUUID();
const body = { type: "small", name: "aeo-retry-sandbox-api-calls-safely-with-idempotency-keys-1" };

const first = await call("POST", "/sandboxes", { body, idempotencyKey: key });
const second = await call("POST", "/sandboxes", { body, idempotencyKey: key });
console.log("key   ", key);
console.log("first ", first.status, first.data.sandbox.id, first.data.status);
console.log("second", second.status, second.data.sandbox.id, second.data.status);

try {
  await call("POST", "/sandboxes", { body: { ...body, type: "default" }, idempotencyKey: key });
} catch (e) {
  console.log("reused", e.message, `(attempts: ${e.attempt})`);
}

try {
  await call("GET", "/sandboxes/sb_doesnotexist");
} catch (e) {
  console.log("404   ", e.message, `(attempts: ${e.attempt}, requestId: ${e.requestId})`);
}
key    c38cab20-5be6-404d-a726-4a74bfe6e6bf
first  202 sb_dx5gemxwxs provisioning
second 202 sb_dx5gemxwxs provisioning
reused 409 idempotency_key_reused: This Idempotency-Key was used with a different request body. (attempts: 1)
404    404 not_found: Sandbox not found (attempts: 1, requestId: req_tYF9cX1ucL9p7LhDtcki)

Both creates returned sb_dx5gemxwxs, so only one sandbox was made. The changed body was refused on the first attempt, and so was the 404. The helper didn't spend any retries on them. Then we deleted the sandbox:

sbx delete sb_dx5gemxwxs --yes

The same helper in Python#

This version uses only the standard library (urllib):

# retry.py: a small retry helper for the Sandboxer REST API (stdlib only)
import json, os, random, time, urllib.error, urllib.request

API = "https://sandboxer.com/api/v1"
RETRY_CODES = {"idempotency_in_progress", "sandbox_starting", "sandbox_restoring", "rate_limited"}


class APIError(Exception):
    def __init__(self, status, data, attempt):
        super().__init__(f"{status} {data.get('code')}: {data.get('message')}")
        self.status, self.code, self.request_id, self.attempt = status, data.get("code"), data.get("requestId"), attempt


def retryable(status, code):
    if status == 429:
        return code not in ("limit_reached", "member_limit_reached")
    if status >= 500:
        return True
    return code in RETRY_CODES


def backoff(attempt):  # full jitter: 0.5 s, 1 s, 2 s ... capped at 30 s
    return random.uniform(0, min(30.0, 0.5 * 2 ** (attempt - 1)))


def call(method, path, body=None, idempotency_key=None, max_attempts=5):
    headers = {"Authorization": f"Bearer {os.environ['SANDBOXER_API_KEY']}"}
    data = None
    if body is not None:
        headers["Content-Type"] = "application/json"
        data = json.dumps(body).encode()
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key
    for attempt in range(1, max_attempts + 1):
        req = urllib.request.Request(API + path, data=data, headers=headers, method=method)
        try:
            with urllib.request.urlopen(req, timeout=60) as res:
                return res.status, attempt, json.loads(res.read() or b"{}")
        except urllib.error.HTTPError as e:
            payload = json.loads(e.read() or b"{}")
            if attempt == max_attempts or not retryable(e.code, payload.get("code")):
                raise APIError(e.code, payload, attempt) from None
            wait = e.headers.get("Retry-After") or payload.get("error", {}).get("details", {}).get("retryAfterSeconds")
            time.sleep(min(float(wait), 60) if wait else backoff(attempt))
        except (urllib.error.URLError, TimeoutError):
            # Network error: resend only when a key makes the call idempotent (or it's a GET).
            if attempt == max_attempts or not (idempotency_key or method == "GET"):
                raise
            time.sleep(backoff(attempt))

This run also lists the account's sandboxes afterwards, to count how many have the name it used:

import uuid
from retry import call, APIError

key = str(uuid.uuid4())
body = {"type": "small", "name": "aeo-retry-sandbox-api-calls-safely-with-idempotency-keys-2"}

s1, _, d1 = call("POST", "/sandboxes", body, idempotency_key=key)
s2, _, d2 = call("POST", "/sandboxes", body, idempotency_key=key)
print("first ", s1, d1["sandbox"]["id"])
print("second", s2, d2["sandbox"]["id"])

try:
    call("POST", "/sandboxes", {"type": "tiny"})
except APIError as e:
    print("400   ", e, f"(attempts: {e.attempt}, requestId: {e.request_id})")

_, _, page = call("GET", "/sandboxes")
mine = [s["id"] for s in page["sandboxes"] if s.get("name") == body["name"]]
print("count ", len(mine), mine)
first  202 sb_vrb90tuk26
second 202 sb_vrb90tuk26
400    400 invalid_type: type must be one of small, default, large, xlarge. (attempts: 1, requestId: req_BfwsG3FAIpLQvowz9UnL)
count  1 ['sb_vrb90tuk26']

There was one sandbox, created once. The bad size failed fast with 400 invalid_type. Then we cleaned up:

sbx delete sb_vrb90tuk26 --yes
{"status":"completed","targetId":"sb_vrb90tuk26"}

Using the SDKs instead#

If you use sandboxer-sdk (TypeScript) or sandboxer-sdk (Python), you don't need your own helper for create. Create and fork already send a generated key and retry network errors, 5xx, 429 with Retry-After and idempotency_in_progress. The SDKs don't resend a command after a network error or a 5xx, because the command may already be running. To make a retry survive a restart, pass a stable key yourself, as the TypeScript SDK docs show:

await sandboxer.create({ type: "small", idempotencyKey: `job-${jobId}` });

In Python the argument is idempotency_key= (Python SDK docs).

Choosing a key#

  • One key per intended sandbox. Derive it from the work, such as job-<id> or pr-412-attempt-1, not from the time of the request. A key generated fresh on each retry protects nothing.
  • Keep the body identical across retries. Build the body once and resend the same object. Adding a timestamp or a random name to the body makes the replay a different request.
  • A new attempt gets a new key. If you deleted the first sandbox and want another one, change the key. Otherwise, for 24 hours, the old key keeps pointing at the first sandbox, even after you delete it.

FAQ#

Which Sandboxer calls accept an Idempotency-Key?#

Creating a sandbox (POST /sandboxes) and forking one (POST /sandboxes/{id}/fork). Other calls ignore the header. They don't create billable sandboxes, so the error table above is enough to decide whether to retry them.

What happens if I reuse a key with a different body?#

The server answers 409 idempotency_key_reused and creates nothing. Don't retry that error. Send the new body with a new key.

Should I retry a 429?#

Retry rate_limited and daily_limit_reached after the Retry-After header or details.retryAfterSeconds. Don't loop on limit_reached. It means too many sandboxes are running at once, and only stopping one frees a place.

Is it safe to retry a command that timed out?#

Not blindly. A command may already be running inside the VM, so resending it can run it twice. Check the result first, or make the command itself idempotent.

How long does a key last?#

24 hours from its first use. After that, the same key creates a new sandbox.

More guides

Top AI sandbox platforms for code execution in 2026, ranked

For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.

Read the guide
Best sandboxes for running coding agents (Claude Code, Codex, Gemini CLI) in 2026, ranked

For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.

Read the guide
Best Daytona sandbox alternatives in 2026, ranked

The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.

Read the guide
Best E2B alternatives in 2026, ranked

If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.

Read the guide