Errors
Handle API and connection errors separately from settled agent outcomes.
What throws and what does not
An agent's own outcome is returned, never thrown. A run that declined the schema, hit its deadline, or crashed comes back as a settled NodeOutcome from waitForOutcome, createAndWait, or parse. Narrow on it — see Nodes.
Exceptions are for the layer underneath: the SDK rejected an invalid local identifier or configuration, the daemon refused the request, the connection failed, the request timed out, or you aborted.
An error without origin was produced by the SDK because the request reached no server (runtime, router, or directory). Show it as a connection failure, never as a refusal. An OAuth ID token that fails verification (signature, issuer, audience, expiry, or a missing or mismatched nonce), or a sign-in-only answer with no ID token, raises the SDK-only CrouterError code id_token_invalid. A directory JWKS that can't be fetched or is malformed is not id_token_invalid: that error is thrown as is. oauth_exchange_mismatch means the wrong exchange call was used: exchangeCode on a sign-in-only answer, or exchangeIdentity on an answer that carries a connection. oauth_nonce_missing means exchangeIdentity was called without the nonce from authorizeUrl. When the directory refuses the app's client assertion (invalid_client) on exchangeCode, a refresh, or revoke, OAuth2Client throws an APIError with code unauthorized and status 401. It is an AuthenticationError, including when the refusal comes from the token refresh inside a client returned by oauth.client(...). The message names the key id (kid) the SDK signed with and asks whether that key's public JWK is registered on the app's Sign-in tab. details is {oauth_error: 'invalid_client', kid}. For a key with no kid, kid is null and details.key_thumbprint holds the key's RFC 7638 thumbprint instead. See Rotating your key.
Every code the SDK raises itself is listed in sdkErrorCodes, and CrouterError.code is typed SdkErrorCode; both are exported from @crouter/sdk/errors alongside the daemon's errorCodes / ErrorCode. That subpath also exports mapError(error), which turns a plain APIError into its status class below (NotFoundError, ConflictError, …), for test doubles that build errors by hand.
For the full app-facing HTTP envelope and the SDK's fixed code tuples, see App-facing error envelope. The socket/v0 wire body below is deliberately smaller.
Classes and mappings
Every class below extends APIError, which carries status, code, message, details, and headers.
| Condition | Class |
|---|---|
| SDK configuration error | CrouterError with status: 0, code: 'crouter_error' |
| HTTP 400 | BadRequestError |
| HTTP 401 | AuthenticationError |
| HTTP 403 | PermissionDeniedError |
| HTTP 404 | NotFoundError |
| HTTP 409 | ConflictError |
| HTTP 413, 422 | UnprocessableEntityError |
| HTTP 429 | RateLimitError |
HTTP 504 or code request_timeout | APIConnectionTimeoutError (extends APIConnectionError) |
| Other HTTP ≥ 500 | InternalServerError |
Code daemon_unavailable, transport_error, daemon_request_interrupted, or daemon_health_unavailable; or a non-API transport failure | APIConnectionError |
Code request_aborted | APIUserAbortError |
| Any other daemon response | APIError |
A terminal SSE error event other than stream_gap rejects NodeStream.finalOutcome() and the iterator with APIError carrying that event's code, message, and details. stream_gap is delivered as an event and the stream continues. See Streaming.
import { ConflictError, NotFoundError, APIConnectionError } from '@crouter/sdk';
try {
await client.nodes.create({ prompt, node_id: 'my-run-42' });
} catch (error) {
if (error instanceof ConflictError && error.code === 'node_id_exists') {
// the run already exists — attach to it instead of spawning a second one
return client.nodes.waitForOutcome('my-run-42');
}
if (error instanceof NotFoundError) throw new Error('no such node');
if (error instanceof APIConnectionError) throw new Error('the daemon is not reachable');
throw error;
}The subclasses add no fields. status and code on the base class already decide everything there is to branch on. They exist so that catch (error) { if (error instanceof NotFoundError) … } — what an OpenAI SDK user writes without thinking about it — works here too.
Identifier validation
The SDK validates path-segment identifiers before it makes a request. Invalid node ids, cron ids, bash-job ids, human-request ids, inbox ticket ids, provider names, and profile names throw TypeError locally. Node ids apply to core node calls, lifecycle calls, and nested node resources. nodes.events() returns a NodeStream synchronously, so its invalid-id TypeError rejects stream.node, stream.finalOutcome(), and iteration instead. File paths are not identifiers; invalid or relative file paths reach the daemon and return its mapped API error. nodes.outcome(id, { wait }) separately throws RangeError when wait is not an integer from 0 through 25.
Scoped tokens
| Condition | Class, status, and code |
|---|---|
The bearer token's ceiling lacks the scope a route needs, or nodes.create asks for scopes outside it (details.scopes lists them) | PermissionDeniedError, 403 scope_denied |
| A scoped token reaches an owner-only route (daemon restart, attach, broker internals, canvas prune, profile pause/resume/delete, model credential install) | PermissionDeniedError, 403 owner_only |
The wire body
The daemon answers an error with:
{ "error": { "code": "node_id_exists", "message": "…", "details": { } } }code is the stable, machine-readable identity — node_id_exists, daemon_unavailable, request_timeout, node_dormant. Branch on it. There is no separate type field and no param field.
Retries
maxRetries defaults to 2, with exponential backoff. It applies to:
- connection errors, and
- HTTP 429 and 5xx responses, on
GET,HEAD, andDELETEonly.
POST and PATCH are never retried automatically. Creating a node, delivering a message, and pushing a report are not idempotent, and a mutation whose response was interrupted may already have been applied — replaying it would spawn a second agent, deliver a second message, or push a second report.
If you want a create that survives a retry, pass node_id. A duplicate then fails loudly with 409 node_id_exists instead of quietly running twice:
const runId = `invoice-${invoice.id}`;
try {
await client.nodes.create({ prompt, node_id: runId, root: true });
} catch (error) {
if (!(error instanceof ConflictError && error.code === 'node_id_exists')) throw error;
}
const outcome = await client.nodes.waitForOutcome(runId);Override the policy per request with { maxRetries: 0 } or { maxRetries: 5 }. Raising it on a POST still does not make the client retry that POST.
In-repo callers
ApiError from @crouter/api is an alias of APIError, so existing crouter code and its transport checks keep working unchanged. Daemon handlers keep throwing { status, code, message } and never name an SDK class — the hierarchy is one status → constructor table on the client side and nothing else depends on it.