# Errors

Every `/v1` error has the same body, a stable `code`, and says whether a retry can succeed.

```json
{"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}
}}
```

- **`code`** is stable; branch on it. The list is open: treat an unknown code by its HTTP status.
- **`message`** is for humans and may change.
- **`retryable`**: `true` means the same request may succeed later. Retryable `429` and `503` always carry a
  `Retry-After` header (integer seconds, 1–30), computed from your queue rather than fixed.
- **`request_id`** is also in the `Shark-Request-Id` header of every response, errors or not. Quote it when you ask for
  help.
- **`details`** carries 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](concepts.md#lease-one-command-one-microvm)).

**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](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](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](sdk-python.md#errors)).

## 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](shark-code.md#what-the-agent-knows).

## Retrying safely

The rule: **a retried request never does the work twice, and never silently does another request's work.**

1. Give every command an `op_id` and every create/fork an `Idempotency-Key`. Reuse it only for the *same* request.
2. Retry on connection errors, on `5xx`, and on any error with `"retryable": true`, after `Retry-After` when present
   (otherwise exponential backoff with jitter, from 0.5 s, capped at 30 s).
3. Do not retry `4xx` with `"retryable": false`; fix the request.
4. A retried `DELETE` that answers `404` means 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.