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). 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).
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.
TypeScriptimport { 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 twiceawait again.stop();Pythonfrom uuid import uuid4from withruntime import Runtimeruntime = 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) # Trueagain.stop()Over HTTP, send the header yourself:
Terminalcurl -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).
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).
- 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). - 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
idempotencyKeybefore the first call to a tool that accepts it (MCP).
Related
- How to handle no_capacity and quota_exceeded
- How to cap what a sandbox can cost
- Agent evals and SWE-bench, where thousands of creates must each happen once
- What is a cold start?
Start
Terminalprintf '%s\n' 'print(sum([125, 250, 375]))' > invoice.pynpx withruntime sandboxes run -- python3 - < invoice.pyA 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.