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 orjob-<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 --yesThe 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>orpr-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.
Related#
More guides
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.
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.
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.
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.