# How to retry sandbox API calls safely with idempotency keys Send the same `Idempotency-Key` with the same body. For 24 hours, Runtime returns the first result again instead of acting twice. **On Runtime, every write already carries a key. Both SDKs retry with that key. So a retried create never makes a second sandbox, and a retried command never runs twice.** This matters for cost as well as correctness. A duplicate 2 vCPU, 4 GiB sandbox left waiting costs $0.03125 an hour, or about $22.50 over 30 days, at the rates checked on 25 October 2026 ([pricing](/docs/pricing)). Choose a key yourself only when a retry must survive a process restart. ## What the SDKs already do A write is any change, such as a create, an exec, a pause or a file write. The SDK makes a key for each call and keeps that key 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 | | `no_credit_running_limit`, `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 immediately as a typed error with a `code` and a `hint` | The typed error keeps the key it sent. It is `error.idempotencyKey` in JavaScript and `error.idempotency_key` in Python. 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 it sends a create, but before it saves the sandbox ID, cannot tell whether the create happened. Derive the key from the job, not from the attempt. Then the worker that picks up the job again gets the same sandbox. Each example below chooses a new job ID once, then reuses it for both calls. A real restarted worker loads the saved job ID from its queue instead. Keep that ID and the input unchanged for every retry. A new job ID means new work, so never generate one inside a retry loop. ```ts import { randomUUID } from "node:crypto"; import { Runtime } from "withruntime"; const runtime = new Runtime(); const job = randomUUID(); 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 uuid import uuid4 from withruntime import Runtime runtime = Runtime() job = str(uuid4()) 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 returns | | ------------------------ | ------------------------------------------------------------------------------------------- | | 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 returns the earlier answer. It is 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 one 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 then sending the old key, is refused with 422. That is a new request, so give it a new key. - **Reading silence as failure.** A missing response does not prove that the call failed. Retry the identical input with the same key. Before you repeat a side effect whose outcome is unknown, check files and processes ([the request timed out](/docs/troubleshooting#the-request-timed-out)). - **Retrying a failed fork with its old key.** A failed fork is final. The same key returns the same error. Fork again with a new key, and stop any copies that 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 makes for a field you leave 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 printf '%s\n' 'print(sum([125, 250, 375]))' > invoice.py npx withruntime sandboxes run -- python3 - < invoice.py ``` A new account gets 100 free hours of a 1 GB machine in its first 30 days, with no card. The first run prints a link to approve in your browser. Facts on this page were checked on 26 October 2026.