# How to retry sandbox API calls safely with idempotency keys Send the same `Idempotency-Key` with the same body, and for 24 hours Runtime answers the first result again instead of acting twice. **On Runtime every write already carries a key, and both SDKs retry with it, so a retried create never makes a second sandbox and a retried command never runs twice.** That matters for money as well as correctness: a duplicate 2 vCPU, 4 GiB sandbox left waiting costs $0.03125 an hour, about $22.50 over 30 days, at the rates checked on 25 September 2026 ([pricing](/docs/pricing)). You only choose a key yourself when a retry has to survive a process restart. ## What the SDKs already do A write is any change: a create, an exec, a pause, a file write. The SDK makes a key for each call and keeps it through its own retries. | Failure | What the SDK does | | ----------------------------- | ---------------------------------------------------------- | | Timeout or dropped connection | Retries with the same key and a growing delay | | 429 `rate_limited` | Waits `retryAfterMs`, then retries with the same key | | 502, 503, 504 | Retries with the same key | | `trial_busy`, `no_capacity` | A create waits for room, up to two minutes by default | | `spending_limit_reached` | Not retried: a person has to raise the limit | | Any other 4xx | Thrown at once as a typed error with a `code` and a `hint` | The typed error keeps the key it sent, as `error.idempotencyKey` in JavaScript and `error.idempotency_key` in Python, so you can log it with the `requestId` ([errors and retries](/docs/javascript#errors-and-retries)). ## Choose your own key when the process can die A worker that crashes after sending a create but before saving the sandbox id cannot tell whether the create happened. Derive the key from the job, not from the attempt, and the worker that picks the job up again gets the same sandbox. ```ts import { Runtime } from "withruntime"; const runtime = new Runtime(); const job = "1842"; const input = { labels: { job } }; const options = { idempotencyKey: `job-${job}-create` }; const first = await runtime.sandboxes.create(input, options); // A restarted worker sends the same call again: const again = await runtime.sandboxes.create(input, options); console.log(first.id === again.id); // true: one sandbox, answered twice await again.stop(); ``` ```python from withruntime import Runtime runtime = Runtime() job = "1842" first = runtime.sandboxes.create(idempotency_key=f"job-{job}-create", labels={"job": job}) again = runtime.sandboxes.create(idempotency_key=f"job-{job}-create", labels={"job": job}) print(first.id == again.id) # True again.stop() ``` Over HTTP, send the header yourself: ```bash no-run curl -sS https://api.withruntime.com/v1/sandboxes \ -H "Authorization: Bearer ${RUNTIME_API_KEY}" \ -H "Idempotency-Key: job-1842-create" \ -H "Content-Type: application/json" \ -d '{"labels": {"job": "1842"}}' ``` ## What the server answers | You send | Runtime answers | | ------------------------ | ------------------------------------------------------------------------------------------- | | Same key, same body | The first result again, with `"replayed": true` and the header `Idempotency-Replayed: true` | | Same key, different body | 422 `idempotency_key_reused` | | A new key | New work | | Any key after 24 hours | New work: the server has forgotten it | A replay is an answer, not a second action. Nothing is created, run or charged again ([retries and idempotency](/docs/api#retries-and-idempotency)). ## Mistakes that cause duplicates - **A fresh key on every attempt.** A retry loop that makes a new UUID each time turns a lost reply into two sandboxes. Make the key once, before the first attempt, and reuse it. - **Changing the body on a retry.** Adding a label or raising `timeoutSeconds` and sending the old key is refused with 422. That is a new request: give it a new key. - **Reading silence as failure.** A missing response does not prove the call failed. Retry the identical input with the same key, and inspect files and processes before repeating a side effect whose outcome is still unknown ([the request timed out](/docs/troubleshooting#the-request-timed-out)). - **Retrying a failed fork with its old key.** A fork that failed is over; the same key answers the same error. Fork again with a new key, and stop any copies the error names in `details.startedSandboxIds` ([forking](/how-to/fork-a-running-sandbox)). - **Omitting the key in a direct MCP call.** A key the server made for a field you left out cannot be recovered from a lost reply. Choose `idempotencyKey` before the first call to a tool that accepts it ([MCP](/docs/mcp#a-typical-session)). ## Related - [How to handle no_capacity and quota_exceeded](/how-to/handle-no-capacity) - [How to cap what a sandbox can cost](/how-to/cap-the-cost-of-a-sandbox) - [Agent evals and SWE-bench](/use-cases/agent-evals-and-swe-bench), where thousands of creates must each happen once - [What is a cold start?](/glossary/cold-start) ## Start ```bash no-run npx withruntime sandbox run --trial -- python3 -c 'print(6 * 7)' ``` New accounts get 50 free sandbox hours, no card. The first run prints a link to approve in your browser. Facts on this page were checked on 25 September 2026.