Error Handling

Separate command failures from machine, transport, and configuration failures. A command can run successfully at the infrastructure level and still return a nonzero exit code.

SDK command results

exec and local run return an ExecResult:

PythonNode.jsMeaning
exit_codeexitCodeGuest process exit code
stdoutstdoutCaptured UTF-8 text
stderrstderrCaptured UTF-8 text
successsuccessExit code is zero
outputoutputCombined output
assert_success()assertSuccess()Raise ExecutionError on nonzero exit

Inspect the result when a nonzero code is part of normal control flow:

result = machine.exec(["sh", "-c", "test -f /workspace/result.json"])
if result.exit_code == 1:
    print("result file is absent")
elif not result.success:
    print(result.stderr)
    raise RuntimeError(f"check failed with {result.exit_code}")

Use assert_success() when any nonzero exit should abort the operation:

result = machine.exec(["python", "-m", "pytest"]).assert_success()
print(result.stdout)

The Node equivalent is:

const result = await machine.exec(["npm", "test"]);
result.assertSuccess();
console.log(result.stdout);

Cloud text output can be truncated. Check stdout_truncated and stderr_truncated in Python, or stdoutTruncated and stderrTruncated in Node. Use byte output, REST streaming where supported, or write large output to a guest file and download it.

SDK exceptions

Both SDKs expose typed errors:

  • ExecutionError: assert_success() or assertSuccess() received a nonzero command exit
  • InvalidConfigError: required configuration is missing or invalid
  • NotSupportedError: the selected local or cloud backend does not implement that operation
  • SmolError: base SDK, engine, transport, readiness, and cloud API error. Inspect its code

Python:

from smol import ExecutionError, SmolError

try:
    machine.exec(["python", "-m", "pytest"]).assert_success()
except ExecutionError as error:
    print(error.exit_code, error.stderr)
except SmolError as error:
    print(error.code, str(error))

Node.js:

import { ExecutionError, SmolError } from "smolmachines";

try {
  (await machine.exec(["npm", "test"])).assertSuccess();
} catch (error) {
  if (error instanceof ExecutionError) {
    console.error(error.exitCode, error.stderr);
  } else if (error instanceof SmolError) {
    console.error(error.code, error.message);
  } else {
    throw error;
  }
}

Do not parse error message text when a type or code is available. Common SDK codes include COMMAND_FAILED, INVALID_CONFIG, NOT_SUPPORTED, NOT_FOUND, UNAUTHORIZED, CONNECTION, TIMEOUT, MACHINE_INTERRUPTED, and INTERRUPTED, which a local stop() or delete() gives a command or transfer it cut off.

Retries

A cloud error also says whether retrying can help: retryable, the HTTP status, the server’s problemCode, and how long to wait (retryAfterMs in Node, retry_after_s in Python).

The SDKs retry on their own, up to three times and waiting as the server asks, when that is safe: a 429 for any call, and a 502, 503 or 504 for reads, deletes, start, stop and resume. Set maxRetries (Node) or max_retries (Python) on the connection to change the count, or 0 to turn retries off.

They never resend an exec or a create, which could run twice. MACHINE_INTERRUPTED means the machine stopped or became unreachable while a command ran: the command may have partly run, and running it again starts it from the beginning. Retry it yourself only when that is safe:

try {
  await machine.exec(["./migrate.sh"]);
} catch (error) {
  if (error instanceof SmolError && error.code === "MACHINE_INTERRUPTED") {
    await new Promise((r) => setTimeout(r, error.retryAfterMs ?? 2000));
    // Re-check what the command already did before running it again.
  } else {
    throw error;
  }
}

REST API errors

Check the HTTP status before decoding a success response. Every error body is RFC 9457 application/problem+json with a stable code, a detail sentence, and retryable. Keep the body and x-request-id for debugging:

body=$(mktemp)
headers=$(mktemp)
trap 'rm -f "$body" "$headers"' EXIT

status=$(curl -sS -D "$headers" -o "$body" -w '%{http_code}' \
  -H "Authorization: Bearer $SMOL_CLOUD_TOKEN" \
  https://api.smolmachines.com/v1/machines)

if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
  printf 'request failed: HTTP %s\n' "$status" >&2
  awk 'tolower($1) == "x-request-id:" { print }' "$headers" >&2
  cat "$body" >&2
  exit 1
fi

cat "$body"

Typical meanings:

  • 400: the body was not JSON, or a well-formed body broke a rule the schema cannot express
  • 401 or 403: missing, invalid, or insufficient credentials
  • 402: tenant billing or budget restriction
  • 404: machine or other resource does not exist
  • 409: lifecycle conflict or duplicate name
  • 422: the body parsed but did not satisfy the schema, or the request was refused by a quota. A quota refusal names the plan and the ceiling it hit, so the body is worth surfacing to whoever triggered the call — see plan limits
  • 429: rate limited; wait Retry-After seconds, then resend. The request never ran
  • 503 with code machine_interrupted: the machine stopped or became unreachable while an exec ran. Wait Retry-After; resend only a command that is safe to run twice
  • 502, 503, 504: briefly unavailable; wait Retry-After, then resend requests that are safe to repeat
  • 500: server error; not marked retryable

Every response with retryable: true carries Retry-After. The status does not separate client errors cleanly, so read code and detail rather than branching on the status alone: a wrong field name, a wrong type and a quota refusal are all 422 (unprocessable), and a body that parses but breaks a semantic rule comes back as 400.

Do not automatically retry invalid input, authentication failures, or arbitrary non-idempotent creates. When a create times out, list or fetch the resource before submitting another create.

The cloud-errors skill packet provokes each of these failures on purpose and shows what a client must branch on, including the ones that arrive as HTTP 200.

The local smolvm serve API uses /api/v1. Generate the OpenAPI document for the installed version:

smolvm serve openapi

smolvm serve is a local control surface and does not currently enforce authentication. Bind it to loopback or a protected Unix socket, not an untrusted network.

CLI exit codes

smolvm machine run and smol run return the guest command’s exit code after cleanup. Other CLI commands return nonzero when the lifecycle or control operation fails.

Preserve the exit status in scripts:

set +e
smolvm machine run --net --image node:22-alpine -- npm test
status=$?
set -e

if [ "$status" -ne 0 ]; then
  printf 'tests failed with exit code %s\n' "$status" >&2
fi

exit "$status"

Do not pipe a command whose status matters without handling pipeline semantics. In Bash, use set -o pipefail; in other shells, capture the workload status before processing its output.

Cleanup

Use a Python context manager for automatic deletion:

from smol import ConnectOptions, Machine

with Machine.create(conn=ConnectOptions(target="local")) as machine:
    machine.run("alpine:3.20", ["sh", "-c", "./job"]).assert_success()

Use finally in Node and for persistent or cloud Python machines:

import { Machine } from "smolmachines";

const machine = await Machine.create(undefined, { target: "local" });
try {
  (await machine.run("alpine:3.20", ["sh", "-c", "./job"])).assertSuccess();
} finally {
  await machine.delete().catch((error) => {
    console.error("machine cleanup failed", error);
  });
}

Do not let a cleanup error hide the original command failure. Log both, then return the workload’s status. Add a cloud TTL when abandoned machines must be deleted after a controller crash.

Debugging

  1. Check machine state before retrying a lifecycle action.

  2. For cloud machines, wait for ready. The started state only confirms that the VM process launched. Machine.create waits; Machine.connect does not.

  3. Read both stdout and stderr and record the command exit code.

  4. Set RUST_LOG=debug when running the CLI to collect engine logs:

    RUST_LOG=debug smolvm machine start --name dev
  5. Run smolvm machine status --name dev and record the installed smolvm --version.

  6. For local REST failures, reproduce against the OpenAPI document emitted by the installed binary.

  7. For cloud REST failures, include the HTTP status and x-request-id in a support report. Remove tokens, injected secrets, and sensitive guest output first.

  8. When a file written earlier is missing, check whether its path is one that empties at each start. /tmp, /run, and /dev/shm are empty after a stop and start, including an automatic idle stop, so a credential or configuration file kept there is gone once the machine restarts.