Back to Microsandbox

Error handling

docs/sdk/errors.mdx

0.6.1614.1 KB
Original Source

Rust, TypeScript, Python, and Go surface typed errors so you can match specific failure modes instead of parsing strings. Rust has an Error enum, TypeScript exposes a dedicated subclass per variant (use instanceof), Python provides dedicated exception classes, and Go provides an *Error value with an ErrorKind discriminator matched via m.IsKind(err, kind) or errors.As. Ruby currently exposes Microsandbox::Error; where no dedicated subclass exists, its reference documents the stable message contract explicitly.

Matching errors

<CodeGroup> ```rust Rust use microsandbox::{Sandbox, Error};

let sb = Sandbox::builder("worker") .image("python") .connect_or_create() .await?;

match sb.exec("python", ["script.py"]).await { Ok(output) if output.status().success => { println!("{}", output.stdout()?); } Ok(output) => { eprintln!("Exit {}: {}", output.status().code, output.stderr()?); } Err(Error::ExecTimeout) => eprintln!("Timed out"), Err(Error::Runtime(msg)) => eprintln!("Runtime: {msg}"), Err(e) => return Err(e), }


```typescript TypeScript
import {
    ExecTimeoutError,
    RuntimeError,
    Sandbox,
} from "microsandbox";

const sb = await Sandbox.builder("worker")
    .image("python")
    .connectOrCreate();

try {
    const output = await sb.exec("python", ["script.py"]);
    if (!output.success) {
        console.error(`Failed (exit ${output.code}):`, output.stderr());
    }
} catch (e) {
    if (e instanceof ExecTimeoutError) {
        console.error(`Timed out after ${e.timeoutMs}ms`);
    } else if (e instanceof RuntimeError) {
        console.error("Runtime:", e.message);
    } else {
        throw e;
    }
}
python
from microsandbox import ExecTimeoutError, Sandbox

sb = await Sandbox.connect_or_create("worker", image="python")

try:
    output = await sb.exec("python", ["script.py"])
    if not output.success:
        print(f"Exit {output.exit_code}: {output.stderr_text}")
except ExecTimeoutError:
    print("Timed out")
go
import (
    "context"
    "errors"
    "log"

    m "github.com/superradcompany/microsandbox/sdk/go"
)

sb, err := m.ConnectOrCreateSandbox(ctx, "worker", m.WithImage("python"))
if err != nil {
    log.Fatal(err)
}

out, err := sb.Exec(ctx, "python", []string{"script.py"})
switch {
case err == nil && !out.Success():
    log.Printf("exit %d: %s", out.ExitCode(), out.Stderr())
case m.IsKind(err, m.ErrExecTimeout):
    log.Println("timed out")
case err != nil:
    // errors.As for deeper inspection.
    var me *m.Error
    if errors.As(err, &me) {
        log.Printf("kind=%s message=%s", me.Kind, me.Message)
    }
}
ruby
require "microsandbox"

sb = Microsandbox::Sandbox.connect_or_create("worker", image: "python")

begin
  output = sb.exec("python", ["script.py"])
  warn "Exit #{output.exit_code}: #{output.stderr}" unless output.success?
rescue Microsandbox::Error => error
  warn error.message
end
</CodeGroup>

Spawn-time exec failures

exec() distinguishes between:

  • A program that ran and exited non-zero: the call returns an ExecOutput with a non-zero code. This is not an error in the SDK sense; it's a normal result.
  • A program that never started: the binary doesn't exist, isn't executable, the working directory is unreachable, etc. This surfaces as a typed error variant: ExecFailed (Rust), ExecFailedError (TypeScript), ExecFailedError (Python).

The typed error carries a classified kind plus the underlying errno, so callers can branch on the cause and react. Common kinds: NotFound (binary missing on PATH), PermissionDenied, NotExecutable, BadCwd, BadArgs, ResourceLimit, UserSetupFailed, OutOfMemory, PtySetupFailed, Other.

<CodeGroup> ```rust Rust use microsandbox::Error; use microsandbox_protocol::exec::ExecFailureKind;

match sb.exec("nonexistent", []).await { Ok(output) => { /* program ran, check output.status() */ } Err(Error::ExecFailed(payload)) => { match payload.kind { ExecFailureKind::NotFound => { eprintln!("Binary not found on PATH: {}", payload.message); } ExecFailureKind::PermissionDenied => { eprintln!("Not executable (chmod +x?): {}", payload.message); } kind => { eprintln!("Spawn failed ({:?}): {}", kind, payload.message); } } // payload.errno, payload.errno_name, payload.stage are also available } Err(e) => return Err(e), }


```typescript TypeScript
import { ExecFailedError, Sandbox } from "microsandbox";

try {
    const output = await sb.exec("nonexistent");
    // program ran, check output.success / output.code
} catch (e) {
    if (e instanceof ExecFailedError) {
        switch (e.kind) {
            case "not_found":
                console.error("Binary not on PATH:", e.message);
                break;
            case "permission_denied":
                console.error("Not executable (chmod +x?):", e.message);
                break;
            default:
                console.error(`Spawn failed (${e.kind}):`, e.message);
        }
        // e.errno, e.errnoName, e.stage are also available
    } else {
        throw e;
    }
}
python
from microsandbox import ExecFailedError

try:
    output = await sb.exec("nonexistent")
    # program ran, check output.success / output.exit_code
except ExecFailedError as e:
    if e.kind == "not_found":
        print(f"Binary not on PATH: {e.message}")
    elif e.kind == "permission_denied":
        print(f"Not executable (chmod +x?): {e.message}")
    else:
        print(f"Spawn failed ({e.kind}): {e.message}")
    # e.errno, e.errno_name, e.stage are also available
go
// Streaming exec surfaces spawn-failure detail via ExecEventFailed.
h, err := sb.ExecStream(ctx, "nonexistent", nil)
if err != nil {
    return err
}
defer h.Close()

for {
    ev, err := h.Recv(ctx)
    if err != nil {
        return err
    }
    switch ev.Kind {
    case m.ExecEventExited:
        // Program ran; inspect ev.ExitCode.
    case m.ExecEventFailed:
        f := ev.Failure // *m.ExecFailure
        switch f.Kind {
        case "not_found":
            log.Printf("Binary not on PATH: %s", f.Message)
        case "permission_denied":
            log.Printf("Not executable (chmod +x?): %s", f.Message)
        default:
            log.Printf("Spawn failed (%s): %s", f.Kind, f.Message)
        }
        // f.Errno (*int), f.ErrnoName, f.Path are also available.
    case m.ExecEventDone:
        return nil
    }
}
</CodeGroup>

The CLI maps these kinds to POSIX-style exit codes: 127 for NotFound, 126 for PermissionDenied and NotExecutable, 1 otherwise. SDK callers reading the error directly don't need to think about exit codes; branch on kind instead.

Name conflicts

Creating a sandbox with a name that's already in use (and without replace) surfaces a typed error you can branch on to decide whether to recover (resume the existing one, regenerate the name, etc.).

<CodeGroup> ```rust Rust use microsandbox::Error;

match Sandbox::builder("worker").image("alpine").create().await { Ok(sb) => { /* ... */ } Err(Error::SandboxAlreadyExists(name)) => { eprintln!("sandbox {name} already exists; resume or pass .replace()"); } Err(e) => return Err(e), }


```typescript TypeScript
import { SandboxAlreadyExistsError } from "microsandbox";

try {
    const sb = await Sandbox.builder("worker").image("alpine").create();
} catch (e) {
    if (e instanceof SandboxAlreadyExistsError) {
        console.error("sandbox already exists; resume or pass replace()");
    } else {
        throw e;
    }
}
python
from microsandbox import SandboxAlreadyExistsError

try:
    sb = await Sandbox.create("worker", image="alpine")
except SandboxAlreadyExistsError:
    print("sandbox already exists; resume or pass replace=True")
go
sb, err := m.CreateSandbox(ctx, "worker",
    m.WithImage("alpine"))
if m.IsKind(err, m.ErrSandboxAlreadyExists) {
    log.Println("sandbox already exists; resume or pass WithReplace()")
}
ruby
begin
  sb = Microsandbox::Sandbox.create("worker", image: "alpine")
rescue Microsandbox::Error => error
  raise unless error.message.include?("already exists")
  warn "sandbox already exists; use connect_or_create to reuse it or replace: true to recreate it"
end
</CodeGroup>

Use connect_or_create and its language-idiomatic equivalents to converge on the existing persisted identity without changing its configuration. Pass replace() / replace=True / replace: true / --replace / WithReplace() only when you intend to stop the existing sandbox and create a new identity. See Naming conflicts for the grace-period knob.

Stale receiver identities

Built-in local and cloud lifecycle receivers capture a stable sandbox identity in addition to the reusable name. If the name now points to a replacement, receiver lifecycle operations refuse to act on it and surface a typed stale-identity error.

<CodeGroup> ```rust Rust match stale_handle.destroy().await { Err(Error::SandboxReplaced { expected, actual, .. }) => { eprintln!("refusing stale operation: {expected} -> {actual}"); } result => result?, } ```
typescript
import { SandboxReplacedError } from "microsandbox";

try {
  await staleHandle.destroy();
} catch (error) {
  if (!(error instanceof SandboxReplacedError)) throw error;
}
python
from microsandbox import SandboxReplacedError

try:
    await stale_handle.destroy()
except SandboxReplacedError:
    pass
go
if err := staleHandle.Destroy(ctx); m.IsKind(err, m.ErrSandboxReplaced) {
    log.Println("refusing stale lifecycle operation")
}
ruby
begin
  stale_handle.destroy
rescue Microsandbox::Error => error
  raise unless error.message.include?("was replaced")
end
</CodeGroup>

Ruby does not yet expose a dedicated stale-identity subclass. Until it does, Microsandbox::Error with the stable was replaced message is the Ruby-specific contract; the operation still refuses to act on the replacement.

Sandbox start failures

When a sandbox process exits before the agent relay is ready (mount errors, missing rootfs, network setup failures), the SDK surfaces a typed BootStart / BootStartError. The payload carries the failure stage and errno so callers can recover or report cleanly.

<CodeGroup> ```rust Rust use microsandbox::Error; use microsandbox_runtime::boot_error::BootErrorStage;

match Sandbox::builder("svc").image("alpine").create().await { Ok(sb) => { /* ... */ } Err(Error::BootStart { name, err }) => { eprintln!("Sandbox {name:?} failed at stage {:?}: {}", err.stage, err.message); if matches!(err.stage, BootErrorStage::Mount) { eprintln!("Hint: a host volume path may not exist."); } } Err(e) => return Err(e), }


```typescript TypeScript
import { BootStartError } from "microsandbox";

try {
    const sb = await Sandbox.builder("svc").image("alpine").create();
} catch (e) {
    if (e instanceof BootStartError) {
        console.error(`Sandbox "${e.name}" failed at stage ${e.stage}: ${e.message}`);
        if (e.stage === "mount") {
            console.error("Hint: a host volume path may not exist.");
        }
    } else {
        throw e;
    }
}
python
from microsandbox import BootStartError

try:
    sb = await Sandbox.create("svc", image="alpine")
except BootStartError as e:
    print(f"Sandbox {e.name!r} failed at stage {e.stage}: {e.message}")
    if e.stage == "mount":
        print("Hint: a host volume path may not exist.")
go
// The Go SDK surfaces boot failures as *m.Error.
// Inspect Kind and Message for the failure details.
_, err := m.CreateSandbox(ctx, "svc", m.WithImage("alpine"))
var me *m.Error
if errors.As(err, &me) {
    log.Printf("sandbox boot failed (kind=%s): %s", me.Kind, me.Message)
}
</CodeGroup>

The CLI prepends the same payload as a styled error: block before any captured log output, so you see "what went wrong + a hint" inline. SDK callers get the structured payload to make their own decisions.

Resource cleanup

<Tooltip tip="Rust drop and TypeScript await using do not stop microsandbox cloud sandboxes, because cloud handles do not own the host process; call stop() or remove() explicitly."><span className="msb-badge-limited">Limited on cloud <Icon icon="circle-info" size={11} /></span></Tooltip>

Sandboxes hold compute resources, so release them when done. In Rust, Drop handles cleanup when the sandbox goes out of scope. In TypeScript, prefer await using (Node 22+) which calls Sandbox.stop() automatically when the binding leaves scope. In Go, pair every CreateSandbox with a defer that calls Stop + Close.

<CodeGroup> ```rust Rust use microsandbox::Sandbox;

// Sandbox implements Drop, so resources are released when sb goes out of scope. // For explicit control, call stop() or kill(). { let sb = Sandbox::builder("temp") .image("python") .create() .await?;

let output = sb.exec("python", ["-c", "print('hello')"]).await?;

} // sb is dropped here, resources are cleaned up


```typescript TypeScript
async function runTemporary(): Promise<string> {
    // `await using` calls Sandbox.stop() when the binding leaves scope.
    await using sb = await Sandbox.builder("temp")
        .image("python")
        .replace()
        .create();

    const out = await sb.exec("python", ["-c", "print('hello')"]);
    return out.stdout();
}
python
# Use async context manager: auto-kills and removes on exit.
async with await Sandbox.create("temp", image="python") as sb:
    output = await sb.exec("python", ["-c", "print('hello')"])
    print(output.stdout_text)
go
sb, err := m.CreateSandbox(ctx, "temp",
    m.WithImage("python"),
    m.WithReplace(),
)
if err != nil {
    log.Fatal(err)
}
defer func() {
    stopCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    _, _ = sb.Stop(stopCtx)
    _ = sb.Close()
}()

out, _ := sb.Exec(ctx, "python", []string{"-c", "print('hello')"})
fmt.Println(out.Stdout())
</CodeGroup>