# Retry Sandbox API Calls Safely With Idempotency Keys

> 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.

Source: https://sandboxer.com/guides/retry-sandbox-api-calls-safely-with-idempotency-keys. Updated 2026-10-27.

**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](/docs/limits): 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:

```typescript
// 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:

```typescript
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})`);
}
```

```text
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:

```bash
sbx delete sb_dx5gemxwxs --yes
```

## The same helper in Python

This version uses only the standard library (`urllib`):

```python
# 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:

```python
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)
```

```text
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:

```bash
sbx delete sb_vrb90tuk26 --yes
```

```json
{"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](/docs/sdk-typescript) show:

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

In Python the argument is `idempotency_key=` ([Python SDK docs](/docs/sdk-python)).

## 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.

## Related

- [Limits and errors](/docs/limits)
- [API reference](/docs/api)
- [Give every user their own sandbox](/guides/give-every-user-their-own-sandbox)
- [Run coding agents in parallel](/guides/run-coding-agents-in-parallel)
