Agent? Fetch sharkmagic.si/docs/llms.txt or this page as markdown
Errors#
Every /v1 error has the same body, a stable code, and says whether a retry can succeed.
{"error": {
"code": "lease_cap_reached",
"message": "too many leases running: tenant cap reached; the command did not start",
"retryable": true,
"request_id": "req_01M6N8R52A3XZP6Z2DTKWFNRRW",
"details": {"scope": "tenant", "limit": 6, "active": 6, "queued": 1, "waited_ms": 30012}
}}
codeis stable; branch on it. The list is open: treat an unknown code by its HTTP status.messageis for humans and may change.retryable:truemeans the same request may succeed later. Retryable429and503always carry aRetry-Afterheader (integer seconds, 1–30), computed from your queue rather than fixed.request_idis also in theShark-Request-Idheader of every response, errors or not. Quote it when you ask for help.detailscarries the field at fault (details.field), limits (limit,used,max_s) or a scope.
A command that fails is not an HTTP error. exec answers 200 with the command's exit; check shark_status
for Shark-side outcomes (conflict, timeout, infra_timeout, infra_error, signaled:
concepts.md).
A busy node is not an infra error. When the node's CPUs are saturated it refuses new work up front
(503 out_of_capacity, details.reason = cpu_pressure or cpu_runqueue), and a command it admitted but could not
start in time (its VM got no CPU to boot) ends as shark_status: "capacity_unavailable", not infra_error. In both
cases the command did not run: resend it with the same op_id after Retry-After. A sync exec gets that
second case as 503 capacity_unavailable (details.reason = lease_start, details.op_id); a stream or an async op
gets it in the result (exit 125, "retryable": true, "retry_after_s"). The CLI and both SDKs retry both on their
own. To see whether a node is admitting work right now: the node_admission row of GET /v1/quotas (state open or
closed, reason, the CPU pressure it read).
Codes#
| HTTP | code |
retryable | when | what to do |
|---|---|---|---|---|
| 400 | invalid_request |
no | malformed JSON or a bad field (details.field); also timeout_s above the mode's maximum (details.max_s, details.mode); GET …/secrets/status with an unknown agent or preset (details.reason = unknown_agent | unknown_preset); GET /ports with an unknown state; an egress host adds details.host and details.reason (ip_without_port, private_range, cidr_without_port, bad_cidr, bad_port, bad_name, invalid_host, too_many_entries) |
fix the request |
| 400 | invalid_op_id |
no | op_id (or Shark-Op-Id) is not 1–64 of [A-Za-z0-9_.:-] |
|
| 400 | path_outside_space |
no | a path or cwd escapes the Space root, or touches reserved .shark/ internals |
use paths relative to the Space root |
| 401 | unauthenticated |
no | missing, malformed, revoked or expired credential; also an account key sent straight to a node (direct mode, SHARK_API set) instead of a node token |
check SHARK_API_KEY; unset SHARK_API; raw HTTP: exchange the key at POST https://api.sharkmagic.si/v1/token (api.md) |
| 402 | credits_exhausted |
no (until you top up) | prepaid balance at zero; new work is refused, reads still work | top up |
| 403 | insufficient_scope |
no | the key lacks the scope (details.required, e.g. network:write) |
use a key with that scope |
| 403 | insufficient_role |
no | account service: your role in the org cannot do it (owner/admin-only routes: API keys, top-up and plan, the Stripe portal, GitHub connect, invitations; a console token with a write scope for a role the console does not know). A team member gets every node scope, secrets:write and network:write included |
ask an owner or admin |
| 403 | user_credential_required |
no | account service: a route about a person (/v1/orgs create and use, /v1/logins) called with an API key, which belongs to one org |
shark login, or create a key in the other org |
| 403 | personal_workspace |
no | account service: inviting or adding someone to a personal workspace, or deleting it | create a team (shark org create) and invite people there |
| 403 | origin_not_allowed |
no | a browser preflight from an origin the node does not serve CORS to (the node's -cors-origins; on the hosted node SHARK_CORS_ORIGINS, default the console's origin) |
call from the dashboard, or with a key outside a browser |
| 403 | plan_restriction |
no | allowed by the API, not for this account (e.g. egress mode: open, public previews) |
|
| 403 | tenant_not_allowed |
no | the body names another account | drop tenant from the body: it comes from the key |
| 403 | tenant_suspended |
no | the account is suspended | contact support |
| 404 | not_found |
no | unknown id or name, deleted, or another account's (never 403, so ids cannot be probed) | |
| 409 | no_billing_customer |
no | account service: the Stripe portal before the account's first payment | top up or subscribe first |
| 409 | idempotency_mismatch |
no | an op_id or Idempotency-Key reused with a different request (details.op_id, details.created_at) |
use a new id for a new request |
| 409 | idempotency_in_progress |
yes | the original request with that key is still running | retry after Retry-After |
| 409 | conflict |
no | the state does not allow it (hibernate while something runs: details.why; a duplicate name, a file write that raced a command on the same path: details.paths) |
|
| 410 | op_expired |
no | the op's stored output passed its retention (7 days); its commit stays in audit |
|
| 412 | precondition_failed |
no | If-Match on a file write does not match the current version |
re-read, then write |
| 413 | payload_too_large |
no | body, stdin or upload above its limit (limits.md) |
stream the file or use the archive upload |
| 416 | invalid_request |
no | a Range the file cannot satisfy |
|
| 422 | unsupported |
no | a valid request for something this node does not serve yet (details.phase); e.g. services API, snapshots, webhooks, exposed ports on a node without them |
see the feature's page |
| 426 | upgrade_required |
no | a terminal route without the WebSocket upgrade | |
| 429 | rate_limited |
yes | request rate per credential (100/s, burst 500) | wait Retry-After |
| 429 | lease_cap_reached |
yes | your account's concurrent commands are at the cap (details.scope = tenant) or the node's (host). The command did not start. |
wait Retry-After, resend with the same op_id |
| 429 | space_limit_reached |
yes | the node's live-Space cap (hibernated Spaces do not count) | retry, or hibernate idle Spaces |
| 429 | quota_exceeded |
no | an account quota (details.quota = spaces: Spaces held; awake: Spaces awake at once, on create, fork or wake; an exposed process's start or wake past the account's awake_services cap is refused with a quota_exceeded message the preview shows; label_spaces / label_awake: a per-label limit, with details.label and selector; with limit, used, limits_class and raise: how to get more) |
spaces/label_spaces: delete Spaces; awake/label_awake: hibernate one (shark hibernate); your caps: shark quotas; more: details.raise |
| 429 | auth_rate_limited |
yes | too many failed authentications from your address | fix the key, then wait |
| 429 | too_many_terminals |
yes | 8 terminals already open on the Space | close one |
| 500 | internal |
yes, for idempotent requests | a bug on our side; request_id identifies it |
retry with the same op_id / Idempotency-Key; report the request_id |
| 503 | out_of_capacity |
yes | the node admits no new command, Space or wake right now (details.reason = memory_headroom | memory_pressure | cpu_pressure | cpu_runqueue; CPU reasons add cpu_psi_some_avg10, runq_per_cpu). Nothing was started. |
wait Retry-After (≥ 2 s for CPU), resend with the same op_id |
| 503 | capacity_unavailable |
yes | a VM or wake could not be placed now; or (details.reason = lease_start, details.op_id) a sync command the node admitted but was too busy to start. That command did not run. |
wait Retry-After, resend with the same op_id: it runs then |
| 503 | canceled |
yes | you disconnected while queued | resend with the same op_id |
| 503 | durable_unavailable |
yes | checkpoint could not reach the durable store |
retry |
| 507 | insufficient_storage |
scope: tenant: no; scope: node: yes |
your account's Spaces exceed their disk quota on the node (limit_mb, used_mb), or the node is low on disk. Deleting files and Spaces still works |
delete files or Spaces / wait |
Warnings (in a 2xx body, warnings: [{secret, code, message}]; the request succeeded):
code |
when | what to do |
|---|---|---|
secret_exposed_to_code |
a secret set with expose: exec: any code in a command that lists it can read the value |
prefer inject |
scope_host_not_allowed |
an inject secret's host is not allowed by the Space's egress policy | shark egress add <space> HOST |
account_secret_not_found |
a Space references ({"ref": "account"}) an account secret that does not exist: commands get nothing under that name until it is set |
shark secrets set --account NAME … |
Secrets routes also answer 400 invalid_request with details.field = secrets.NAME.apply (apply is all or
ref, account secrets only) or secrets.NAME.ref (ref is "account", a Space's secrets only, with no value), and
422 unsupported on a node without the sealed store.
SDK-only codes (raised by the SDKs, never sent by the API): wait_timeout (Python op.wait(timeout=…) ran out
of time; the command keeps running), command_failed, infra_error, connection, config
(sdk-python.md).
Lines Shark adds to a command's output#
A command that ran (or was refused before it started) may end with one shark: line on stderr from Shark itself.
They are meant for coding agents as much as for people: one line, what happened, the next step.
| line | when | next step |
|---|---|---|
shark: egress: connection to HOST:PORT blocked by this Space's egress policy (…); … ask the user to run … (the line ends with the exact shark egress add command) |
the command connected to a host its Space does not allow (no-NIC egress; with the classic network the program only sees a refused connection) | shark egress add SPACE HOST |
shark: background process "CMD" (pid N) was stopped when this command finished; nothing outlives a command here… |
a process started with & was still running at the end |
use it within the command, or an exposed process / shark run --detach |
shark: N background process(es) of this command keep running as job bg-N (CMD); listening on 127.0.0.1:PORT…; output: FILE; ps/pkill do not see it: … |
a node that keeps background jobs (hosted shark code): the process runs on in its own VM |
shark ps, shark logs bg-N, shark kill bg-N when done |
shark: capacity: … (the host is busy; the command did not run; retry…) |
the host could not start the command's VM | run it again after a few seconds |
shark: quota: … |
the Space's CPU, memory or process limits | lower parallelism |
shark: browser: not found in this VM. … |
a command naming a browser exited 127 (image without a browser) | install the browser in the Space; use it within one command |
shark: browser: that name is not installed; this image's headless browser is … |
a command named another browser; the image has chrome-headless-shell | $CHROME_PATH, or shark screenshot URL out.png |
Inside shark code, commands also get $SHARK_MODE (devbox), $SHARK_SPACE and $SHARK_CONTEXT (JSON: what
works in this session); shark context --json prints it. See shark-code.md.
Retrying safely#
The rule: a retried request never does the work twice, and never silently does another request's work.
- Give every command an
op_idand every create/fork anIdempotency-Key. Reuse it only for the same request. - Retry on connection errors, on
5xx, and on any error with"retryable": true, afterRetry-Afterwhen present (otherwise exponential backoff with jitter, from 0.5 s, capped at 30 s). - Do not retry
4xxwith"retryable": false; fix the request. - A retried
DELETEthat answers404means your first attempt worked.
The CLI and both SDKs already do all four. The SDK error classes map one-to-one to the codes (Python:
RateLimitError, LeaseCapError, QuotaExceededError, CreditsExhaustedError, IdempotencyMismatchError,
NotFoundError, UnsupportedError, …; each has .code, .status, .retryable, .retry_after, .request_id,
.details).
Streams#
On an SSE stream (exec with Accept: text/event-stream, /ops/{op_id}/events), the last event is always exit or
error; an error event carries {"code", "message"} with the codes above. A stream that drops before either is
not an error: reconnect to /ops/{op_id}/events with Last-Event-ID and you get the rest, each event once.
