Appearance
Error Handling
Every failed snaptrude.* call rejects with a typed PluginError carrying a stable string code, structured details, and — where the host can infer one — a machine-actionable hint. Branch on code, not on message text: messages may change, codes never do.
ts
import { snaptrude, PluginError } from "@snaptrude/plugin-client";
try {
const result = await snaptrude.design.boolean.union(solids);
} catch (e) {
if (!PluginError.is(e)) throw e; // not an API failure — rethrow
switch (e.code) {
case "VALIDATION":
console.log(e.details?.issues, e.hint); // which argument, what was wrong
break;
case "RATE_LIMITED":
await sleep(e.details?.retryAfterMs as number);
// retry the call
break;
case "HANDLE_INVALID":
// e.handle names the stale id — re-query instead of using cached handles
break;
default:
console.error(`${e.code} [${e.errorId}]: ${e.message}`);
throw e;
}
}Use PluginError.is(e), not instanceof. If your bundle ships its own copy of the SDK, instanceof breaks across the two copies; the brand check always works. Category subclasses (PluginValidationError, PluginQuotaError, …) are exported for convenience and work under a single SDK copy.
The PluginError fields
| Field | Type | Meaning |
|---|---|---|
code | string | Stable machine-readable code — the branching key (table below) |
message | string | Human-readable description. For INTERNAL this is a generic reference — see below |
errorId | string | Per-occurrence id correlating your error with the host-side log entry. Include it in bug reports |
details | object? | Code-specific structured data — issues, retryAfterMs, didYouMean, handles, … |
handle | string? | The primary offending handle id, when the failure is handle-scoped |
details.handles | string[]? | ALL offending handle ids for multi-input operations (always ids you passed in) |
methodPath | string? | The dotted API path that failed, e.g. "design.boolean.union" |
hint | string? | One actionable sentence on how to fix the call |
category | string | Derived family of the code (validation, quota, timeout, …) |
Error codes
Codes are a closed, append-only union — new codes may appear in future hosts (handle unknown codes with a default: branch), existing ones never change meaning.
Your call was wrong
| Code | When | Act on |
|---|---|---|
VALIDATION | An argument failed schema validation | details.issues[] — {path, code, message} per problem |
INVALID_RPC_FORMAT | The RPC payload itself was malformed (usually an SDK/version problem) | — |
METHOD_NOT_FOUND | No such API method | details.didYouMean — closest real method |
HANDLE_INVALID | A handle could not be resolved — not found, disposed, or not yours. Deliberately ONE code for all resolution failures | handle, re-query the entity |
HANDLE_KIND_MISMATCH | Reserved. Today a wrong-kind handle also reports HANDLE_INVALID | handle |
PRECONDITION_FAILED | The call is valid but the model/state doesn't allow it (unknown storey, mesh-less component, locked underlay, …) | details names the violated condition; hint says what to establish first |
The environment said no
| Code | When | Act on |
|---|---|---|
METHOD_NOT_PERMITTED | The API is disabled for this plugin (e.g. write APIs off) | — |
RATE_LIMITED | Too many calls | wait details.retryAfterMs, then retry |
RESOURCE_QUOTA_EXCEEDED | Handle arena full | release handles (core.handles.release, scopes, await using) |
TIMEOUT | The host bounded your call's execution time | retry once; if persistent, reduce the work per call |
The operation failed
| Code | When | Act on |
|---|---|---|
OPERATION_FAILED | The engine could not perform a valid request (CSG failed, placement blocked, …) | details.reason / details.engineCode where available |
RESULT_NOT_SERIALIZABLE | The method result could not cross the worker boundary | report with errorId — usually a host bug |
RESULT_TOO_LARGE | Result exceeded transport limits | narrow the query |
BATCH_PARTIAL | Reserved: a batch completed partially (per-item outcomes on the success channel) | — |
Infrastructure
| Code | When | Act on |
|---|---|---|
TRANSPORT_LOST | The host connection died mid-call | not retryable — the plugin is being torn down |
PLUGIN_NOT_READY / PLUGIN_TERMINATED | Lifecycle states | — |
CALL_TIMEOUT | Reserved for a client-side deadline | — |
INTERNAL | Unexpected host-side fault | see below |
UNKNOWN | The host predates structured errors (legacy fallback) | — |
INTERNAL errors and errorId
When something unexpected breaks inside the host, you get a deliberately generic message:
Internal error. Reference: 3f2a9c1e-…The real failure (message, stack, method, plugin) is logged host-side under that errorId. Nothing about host internals crosses into the plugin sandbox. Include the errorId when reporting the problem — it is the lookup key for the actual cause.
Retry guidance
Only two codes are worth an automatic retry: RATE_LIMITED (after details.retryAfterMs) and TIMEOUT (once). Everything else is deterministic — the same call will fail the same way until you change the call or the model state.
Legacy note
Hosts also populate a legacy error string alongside the structured envelope, and old plugins that string-match it keep working. New code should never parse messages — branch on code.
Per-API errors
Each namespace page documents its specific error conditions in an Errors section — which codes it throws, when, and what lands in details.