Files
OpenShell/sdk/go/docs/src/error-handling.md
T
Mrunal Patel 39cf4823f7 feat(api): add structured gateway errors and SDK decoding (#3313)
* feat(api): expose structured gateway errors across SDKs

Refs #3051. Add standard validation, conflict, and retry details; preserve raw transport status in Rust, Go, TypeScript, and Python; document status and recovery guidance.

This is the structured-error foundation only. Mutation result shapes, allow_missing, durable request deduplication, and exec retry semantics remain follow-up work.

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>

* fix(python): preserve wrapped RPC cleanup handling

Inspect the original gRPC call when handling missing sandboxes during deletion waits and managed cleanup. Add intercepted cleanup regressions and clarify the error-wrapper migration contract.

Addresses the cleanup review on #3313; part of #3051.

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>

---------

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>
2026-09-15 17:51:11 +00:00

6.6 KiB

Error Handling

Gateway status failures and SDK validation failures use *v1.StatusError, which carries a machine-readable Code and a human-readable Message. Local I/O, transport setup, and sentinel errors may use other Go error types. The SDK provides predicate functions for classified status errors.

StatusError

When an operation returns a classified status error, inspect it directly or use the convenience predicates below. Use errors.Is for documented sentinel errors such as ErrTransportNotAvailable.

var se *v1.StatusError
if errors.As(err, &se) {
    fmt.Printf("code: %s, message: %s\n", se.Code, se.Message)
    // se.ErrorInfo and se.FieldViolations contain optional structured details.
}
Field Type Description
Code ErrorCode Machine-readable error classification
Message string Human-readable error description
GRPCCode int32 Original gRPC code before SDK classification
FieldViolations []FieldViolation Rejected request fields and descriptions
ErrorInfo *ErrorInfo Server reason, domain, and metadata
RetryDelay *time.Duration Suggested minimum delay for an otherwise safe retry
Cause error Original gRPC error, including unknown details

Use status.FromError(err) to inspect the complete gRPC status through the SDK's error wrapper. A delay does not prove that a timed-out mutation was not applied.

Predicate Functions

Use these top-level functions to check error types. They work with wrapped errors via errors.As.

Function ErrorCode When it fires
v1.IsNotFound ErrorNotFound Resource does not exist (sandbox, provider, etc.)
v1.IsAlreadyExists ErrorAlreadyExists Resource with that name already exists
v1.IsConflict ErrorConflict Optimistic concurrency violation or invalid state transition
v1.IsUnavailable ErrorUnavailable Gateway is unreachable or client is closed
v1.IsUnimplemented ErrorUnimplemented Operation not supported by the gateway version
v1.IsPermissionDenied ErrorPermissionDenied Insufficient permissions
v1.IsInvalidArgument ErrorInvalidArgument Invalid request parameters
v1.IsDeadlineExceeded ErrorDeadlineExceeded Operation timed out
v1.IsCancelled ErrorCancelled Operation was cancelled (context cancellation)

For ErrorInternal (server-side errors), no convenience predicate exists. Match it directly via the Code field:

var se *v1.StatusError
if errors.As(err, &se) && se.Code == v1.ErrorInternal {
    fmt.Println("Internal server error:", se.Message)
}

Common Patterns

Not Found

Handle missing resources gracefully:

sb, err := client.Sandboxes().Get(ctx, "default", "my-sandbox")
if v1.IsNotFound(err) {
    fmt.Println("Sandbox does not exist, creating...")
    sb, err = client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSpec{}, nil)
}
if err != nil {
    log.Fatal(err)
}

Already Exists

Guard against duplicate creation:

_, err := client.Providers().Create(ctx, "default", &v1.Provider{
    Name: "openai",
    Type: "openai",
})
if v1.IsAlreadyExists(err) {
    fmt.Println("Provider already registered, skipping")
} else if err != nil {
    log.Fatal(err)
}

Alternatively, use Ensure for idempotent registration:

// Create-or-update in a single call
provider, err := client.Providers().Ensure(ctx, "default", &v1.Provider{
    Name: "openai",
    Type: "openai",
})

Conflict (Optimistic Concurrency)

When two clients modify the same resource concurrently, the second write receives a conflict error. Retry by re-reading the resource:

sb, _ := client.Sandboxes().Get(ctx, "default", "my-sandbox")

result, err := client.Sandboxes().AttachProvider(ctx,
    "default", "my-sandbox", "openai", sb.ResourceVersion,
)
if v1.IsConflict(err) {
    // Another client modified the sandbox — re-read and retry
    sb, _ = client.Sandboxes().Get(ctx, "default", "my-sandbox")
    result, err = client.Sandboxes().AttachProvider(ctx,
        "default", "my-sandbox", "openai", sb.ResourceVersion,
    )
}
if err != nil {
    log.Fatal(err)
}

Unavailable

Handle gateway connectivity issues:

sb, err := client.Sandboxes().Get(ctx, "default", "my-sandbox")
if v1.IsUnavailable(err) {
    fmt.Println("Gateway is not reachable, check connection")
    // Implement retry with backoff
}

Unimplemented

Detect unsupported operations gracefully:

_, err := client.Sandboxes().GetLogs(ctx, "default", "my-sandbox")
if v1.IsUnimplemented(err) {
    fmt.Println("Log retrieval not supported by this gateway version")
}

Retry with Backoff

For transient errors like Unavailable or DeadlineExceeded, use exponential backoff only when the operation is idempotent or carries an idempotency/concurrency token. A timeout does not prove that a mutating request was not applied.

func withRetry(ctx context.Context, maxAttempts int, fn func() error) error {
    backoff := 100 * time.Millisecond

    for attempt := 0; attempt < maxAttempts; attempt++ {
        err := fn()
        if err == nil {
            return nil
        }

        // Only retry transient errors
        if !v1.IsUnavailable(err) && !v1.IsDeadlineExceeded(err) {
            return err
        }

        // Add jitter to prevent thundering herd after outages
        jitter := time.Duration(rand.Int63n(int64(backoff) / 2))

        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(backoff + jitter):
            backoff *= 2
        }
    }
    return fmt.Errorf("exhausted %d retry attempts", maxAttempts)
}

Usage:

var sb *v1.Sandbox
err := withRetry(ctx, 3, func() error {
    var e error
    sb, e = client.Sandboxes().Get(ctx, "default", "my-sandbox")
    return e
})

Context Cancellation

All SDK methods accept a context.Context. Use context deadlines and cancellation to control timeouts:

// 5-second timeout for a single call
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()

sb, err := client.Sandboxes().Get(ctx, "default", "my-sandbox")
if v1.IsDeadlineExceeded(err) {
    fmt.Println("Request timed out")
}

See also: Testing for how the fake client returns the same error codes.