crouter
SDK

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.

ConditionClass
SDK configuration errorCrouterError with status: 0, code: 'crouter_error'
HTTP 400BadRequestError
HTTP 401AuthenticationError
HTTP 403PermissionDeniedError
HTTP 404NotFoundError
HTTP 409ConflictError
HTTP 413, 422UnprocessableEntityError
HTTP 429RateLimitError
HTTP 504 or code request_timeoutAPIConnectionTimeoutError (extends APIConnectionError)
Other HTTP ≥ 500InternalServerError
Code daemon_unavailable, transport_error, daemon_request_interrupted, or daemon_health_unavailable; or a non-API transport failureAPIConnectionError
Code request_abortedAPIUserAbortError
Any other daemon responseAPIError

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

ConditionClass, 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, and DELETE only.

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.

On this page