Errors
How both gateways report failure on the wire: the body shape, every status they return, every machine-readable code, and which failures are worth retrying. This page is the HTTP contract; no SDK code appears on it. For the client-side error types that wrap these responses, see the SDK reference.
Contents
Error body
Every error from either gateway is a JSON object with a human-readable error. Some handlers add a machine-readable code. Branch on the status or on code, never on the prose. The prose is not stable across releases.
| Field | Type | Description |
|---|---|---|
error | string | Human-readable message. Always present. Not stable, so do not parse it. |
code | string | Machine-readable discriminator, when the handler sets one. See code values. |
detail | string | Both gateways. Upstream diagnostic, truncated to 512 characters on the browser gateway (the VM gateway's is not length-limited). Prose. Do not branch on it. |
message | string | VM gateway only. Alternate message field some clients read. |
retryable | boolean | Both gateways. true marks a transient failure an idempotent client should retry with backoff. Prefer this over the bare status — it is the only way a non-5xx response (e.g. the browser gateway's 404 {"code":"ReplayPending"}) can still say "retry me". |
Individual errors carry extra context alongside these: plan, cap, feature, limit, supported, profileId. They are documented on the endpoints that emit them in the API reference. The object is open: treat unknown fields as additive.
{
"code": "ConcurrencyLimitExceeded",
"error": "Concurrent session limit reached (20)",
"plan": "starter",
"cap": 20
}Status codes
Successes are 200, except 201 on create, 202 on an async template build, 204 on browser release, and 101 on a WebSocket upgrade. A VM exec whose command exits non-zero is still 200. The call succeeded, the command failed. Read exitCode.
| Status | Meaning | Retry? |
|---|---|---|
400 | Malformed body, or a cross-field rule was broken. proxy and captcha require stealth: true on the browser gateway; the VM gateway rejects bad template and recording combinations here. | No |
401 | No Authorization header, a non-Bearer scheme, or a key that failed verification. | No |
402 | The plan lacks a requested feature (FeatureRequiresPlan), or the prepaid balance is exhausted (InsufficientCredit, VM). | No |
403 | Profile cap reached (PlanLimitExceeded, browser) or the plan excludes VMs (NotEntitled). | No |
404 | Deliberately opaque. An unknown ID, a forged signature, and another org's valid ID are indistinguishable by design, so existence cannot be probed. | No |
409 | Resource-state conflict. The profile editor is open or a version clashed (browser); a template is still building or a snapshot still has live children (VM). | No |
413 | Content-Length exceeded the route cap. Enforced from the header before the body is parsed; limit echoes the cap in bytes. On the browser gateway: 16 KB by default, 1 MB on POST /profiles/:id/save. On the VM gateway: 32 MiB on POST /files/upload and other file-body routes. | No |
429 | ConcurrencyLimitExceeded. The org is at its concurrent-session cap. The cap is per plan — see Plans & pricing. Free is 3 browsers and 1 sandbox. | No, see retry semantics |
500 | Unexpected failure. The VM gateway defensively tears down any already-live VM, undoes partial writes, and frees the reservation before returning this. | Maybe |
501 | VM gateway only. The feature is not available, not merely busy. Port preview returns this with no previewDomain. A platform fact, not a transient one: retrying never helps. | No |
502 | An upstream was unreachable or errored: the control plane (browser), or a host that rejected an assign, restore, or pause (VM). | Yes |
503 | Capacity or a dependency. No pool or host had free capacity; the concurrency store is wedged (ConcurrencyCheckUnavailable, browser); or admission is unreachable (VM). The last two fail closed rather than let an org slip past its cap or balance gate. | Yes |
504 | Not emitted by either gateway. It comes from the load balancer in front of them when a request outlives its timeout. The work may still have completed. | Yes, if idempotent |
507 | VM gateway only, on POST /sandboxes and POST /desktops. Your request is a valid size — it already passed the per-request caps below 402 — but no host currently has room for it. This is a live placement race, not a spec problem: every size the gateway accepts fits an empty host by construction, so the same request succeeds once capacity frees up. Carries Retry-After. Do not shrink the request in response to this status; see hint in the body if you want to start sooner instead of retrying as-is. | Yes |
Code values
The complete set. Fifteen values across the two gateways; a handler that does not set one leaves code absent, so always tolerate its absence.
code | Status | Gateway | Meaning |
|---|---|---|---|
FeatureRequiresPlan | 402 | Both | The plan lacks a requested feature: stealth, proxy, captcha, desktops, custom templates. Carries feature and plan. |
ConcurrencyLimitExceeded | 429 | Both | At the concurrent-session cap. Carries plan and cap. Not retryable. |
PlanLimitExceeded | 403 | Browser | Profile cap reached. Generated by the control plane and forwarded verbatim; the gateway never mints it. |
ConcurrencyCheckUnavailable | 503 | Browser | The concurrency store is wedged. The gateway fails closed rather than let an org blow past its cap. Retryable. |
InvalidSessionId | 404 | Browser | The session ID was malformed, forged, or another org's. Nothing was released. Do not treat as success. |
NotEntitled | 403 | VM | The plan does not include desktops or sandboxes. |
InsufficientCredit | 402 | VM | Admission rejected on prepaid balance. |
InsufficientCapacity | 507 | VM | No host has room for this size right now. The request itself is valid — it already passed ValidationError — this is a fleet placement failure. Carries retryable: true and a hint. Not the same failure as an oversized request; see the example below. |
TemplateKindMismatch | 400 | VM | The custom template's kind does not match the requested kind. |
TemplateNotReady | 409 | VM | The template exists but is still building, or failed. |
TemplateBuilding | 409 | VM | A template cannot be deleted while its build is running. |
SnapshotHasChildren | 409 | VM | Live VMs still descend from this snapshot. |
HostCapabilityMissing | 409 | VM | The request needs a host feature (today: volumes.mount) the pool it would land on doesn’t advertise yet. Retryable — a different pool or a later rebake can succeed. |
VolumeAlreadyAttached | 409 | VM | A volume passed to a create is already mounted into another of your sandboxes or desktops — a volume is single-attach. The body names the sandbox that holds it. |
VolumeStatusUnknown | 503 | VM | Could not determine whether a volume is already attached (a durable-store read failed). Retryable — fails closed rather than risking a double-attach. |
VolumeInUse | 409 | VM | DELETE /volumes/:id on a volume still attached to a live sandbox or desktop. Detach it first. |
LocalFilesUnsupported | 400 | VM | compiled.localFiles is not implemented. Fetch files inside the build instead. |
RecordingRequiresDesktop | 400 | VM | record: true on a headless kind: "sandbox". There is no display to capture. |
RecordingRequiresGoldenBoot | 400 | VM | record: true combined with fromSnapshot or a tpl_… template. The host cannot wire recording on the restore path. |
The TypeScript SDK's error union names BrowserUnhealthy, but no gateway response ever carries it. The client synthesizes it when its own post-connect probe fails or times out. Do not write an HTTP handler expecting it. See the browser SDK reference for the client-side union.
Retry semantics
502, 503, 504, 507 (VM gateway only) and transport errors (connection reset, DNS failure, timeout) are the retryable class: an upstream was unreachable or capacity was briefly unavailable, and the same request may well succeed a moment later. Back off with jitter. Everything in the 4xx range is deterministic. The same request will fail the same way until you change the request, the plan, or the resource state.
ConcurrencyLimitExceeded means your org is already at its concurrent-session cap. Retrying cannot help: a slot only frees when you pause or kill a session. Neither the browser client nor the VM client retries a 429. It propagates to your caller on the first response, and backing off is your job. A tight retry loop here burns quota against a wall.
A transient failure on either gateway also carries "retryable": true in the body. Prefer that signal over the bare status. Send Idempotency-Key: <uuid-v4> on create routes to make the retry safe: the gateway remembers the first 2xx per (org, key) for 24 hours and replays it byte-for-byte, same sandboxId/sessionId, with the response header Idempotent-Replayed: true. The same key with a different route or body is 409 IdempotencyKeyReused; failures (including 429) are not remembered, so a retry really retries. Without a key, a retried create can double-charge you a VM. GET and DELETE are idempotent by nature: deleting an already-gone VM still returns 200 {"ok": true}.
What the SDKs do
The two clients do not share a retry policy. If you are writing against the wire directly, this is the behaviour you are replacing.
| Behaviour | Browser client | VM client |
|---|---|---|
| Retries which statuses | 502, 503, or 504 unconditionally; also 507 or any other status carrying retryable: true in the body, but only on a request that is safe to replay (see the next row). A 500 or 501 is surfaced immediately. | Any 5xx, or any body with retryable: true |
| Retries which requests | 502/503/504 retry any request, including non-idempotent creates — the gateway issues no Idempotency-Key for this API, so there is nothing safer to gate on. The retryable: true body hint (e.g. the replay-url's 404 ReplayPending) is honored only on GET/HEAD/DELETE/PUT, or a request carrying an Idempotency-Key. | Idempotent only: GET, DELETE, or a request carrying an Idempotency-Key |
| Retries a 429 | No | No |
| Attempts | maxAttempts: 2 is the total, so the default is one try plus one retry | maxRetries: 5 counts retries on top of the first attempt, so up to six tries |
| Backoff | Fixed 500ms, no jitter | Exponential with jitter, 150ms doubling to an 8s cap |
| Timeout | 90s, applied per attempt | 300s, applied per attempt |
Both clients arm a fresh timer on every attempt, so the wall-clock ceiling for a call is roughly attempts × timeout plus backoff, not the timeout you configured. Budget for the total if you are wrapping these calls in a deadline of your own.
Errors on the wire
A bad key on the browser gateway. The body is the minimal envelope: error and nothing else.
curl -sS -i https://api.getsolari.com/profiles \
-H "Authorization: Bearer slr_live_not_a_real_key"HTTP/1.1 401 Unauthorized
content-type: application/json
{"error":"Unauthorized"}A capacity failure on the VM gateway — a valid request, no host free to place it on right now. retryable is the signal to branch on, and this is worth sending again after a backoff; the SDKs already do.
{
"code": "InsufficientCapacity",
"error": "No host currently has room for a sandbox this size. Your request is valid — the fleet is busy right now — so retrying should succeed as capacity frees up.",
"hint": "Retry with the same spec. A smaller cpu/memMb will fit sooner if you need to start immediately.",
"retryable": true
}InsufficientCapacity (507) means a valid size could not be placed at this instant — it is not saying your spec is too big. The too-big case is a 400 ValidationError, returned before any host is even consulted, and it names the offending field:
{
"code": "ValidationError",
"error": "cpu must be 8 or less (asked for 16)",
"field": "cpu"
}If you get 507, retry the same request. If you get 400, shrinking the request is what actually helps.
A create with a non-empty volumes[], on a host pool that doesn’t carry the gcsfuse mount build yet. Today that’s any desktop — volumes work on sandboxes in every isolation mode. Unlike a platform-fact 501, this is a 409 because the missing piece is which host pool you land on: a different pool, or the same pool after its next rebake, can succeed.
{
"code": "HostCapabilityMissing",
"error": "does not support: volumes.mount",
"missingCapabilities": ["volumes.mount"]
}Branch on the status first, then on code. Capturing the body and the status separately keeps both available:
STATUS=$(curl -sS -o /tmp/resp.json -w '%{http_code}' \
-X POST https://api.getsolari.com/sandboxes \
-H "Authorization: Bearer $SOLARI_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"template":"base","cpu":2,"memMb":2048}')
case "$STATUS" in
2*) jq -r .sandboxId /tmp/resp.json ;;
429) echo "at cap: pause or kill a session, retrying will NOT help" ;;
502|503|504) echo "transient: resend with the SAME Idempotency-Key" ;;
*) jq -r '"\(.code // "no-code"): \(.error)"' /tmp/resp.json ;;
esacTraps
GET /sessions/:idanswers from gateway state, not the browser.statusis one ofactive,released,expired,unknown. Areleasedsession was ended byDELETE /sessions/:idor server-side (client disconnected); polling for it is the supported way to confirm a release.unknownmeans the owning pool is not registered on the replica that answered, so liveness cannot be confirmed; it is not a failure. A well-formed id from your own org never 404s. See the endpoint's entry.- A 404 on release is not a no-op.
DELETE /sessions/:idacks204even for an already-released session, so a404(InvalidSessionId) there means the release did not happen. Do not treat it as success. - Not every 5xx is transient.
501is a configuration fact and500is a bug; only502/503/504reliably reward a retry. - A revoked key can keep working for up to 60 seconds. The browser gateway caches verified bearers, so revocation is not instant unless the control plane invalidates it first.
- A
200fromexecdoes not mean your command worked. The HTTP call succeeded; the command's ownexitCodeis in the body.
For the routes these errors come from, see the API reference. If you hit an error that isn't listed here, or one whose behaviour doesn't match this page, email hello@getsolari.com.
