Errors
Handle API and connection errors separately from settled agent outcomes.
Errors
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.
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 '@north-light/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 |
Memory requests
| Condition | Class, status, and code |
|---|---|
| retrieve or resolve names a document that does not exist, including a document deleted earlier | NotFoundError, 404 memory_document_not_found |
| history names a document with no document or revision history | NotFoundError, 404 not_found |
| History is requested for a builtin or plugin document | BadRequestError, 400 usage |
| A mutation directly sets protected frontmatter (kind, when-and-why-to-read, origin, or last-updated) | BadRequestError, 400 invalid_request |
| An update would make no change | BadRequestError, 400 usage |
| A node-targeted caller lacks memory:read or memory:write | PermissionDeniedError, 403 scope_denied |
Invalid names, invalid scope combinations, invalid limits, builtin mutation selections, and invalid search combinations are also BadRequestError responses. Builtin and plugin documents cannot be mutated; no code should treat a 400 refusal as a successful write.
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 @north-light/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.