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 | Browser gateway only. Upstream diagnostic, truncated to 512 characters. Prose. Do not branch on it. |
message | string | VM gateway only. Alternate message field some clients read. |
retryable | boolean | VM gateway only. true marks a transient failure an idempotent client should retry with backoff. Prefer this over the bare status. |
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 | Browser gateway only. Content-Length exceeded the route cap (16 KB by default, 1 MB on POST /profiles/:id/save). Enforced from the header before the body is parsed; limit echoes the cap in bytes. | 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 configured on this deployment: every volumes route returns 501 when VOLUMES_TABLE is unconfigured, and port preview returns it with no previewDomain. A configuration fact, not a transient one. | 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 |
Code values
The complete set. Fourteen 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. |
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. |
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 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.
On the VM gateway a transient failure 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. Without one, 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, 504 only. A 500 or 501 is surfaced immediately | Any 5xx, or any body with retryable: true |
| Retries which requests | All of them, including non-idempotent creates | 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. retryable is the signal to branch on, and this request is worth sending again after a backoff.
{
"error": "No sandbox host available",
"retryable": true
}Volumes on a deployment with no VOLUMES_TABLE. A 501 is a fact about the deployment. Retrying it will return the same thing forever.
{
"error": "Volumes are not configured"
}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/:idalways 404s. The browser gateway proxies to a pool route that does not exist, so every well-formed request to a live session returns a 404 whose body is the plain-text string404 Not Foundserved under a JSON content type. It will not parse as JSON. Do not build against it. 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.
