Errors
What throws and what settles as an outcome, the error classes, refusals, the error envelope, and which calls the SDK retries.
What throws and what does not
A run's own outcome is returned, never thrown. A run that declined the schema, hit its deadline or failed comes back as an outcome from runs.wait or runs.parse. Narrow on it: see Typed results. Only transport faults, runtime or directory refusals, bad local configuration, and your own abort throw.
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.
Codes the SDK raises itself are listed in sdkErrorCodes, and CrouterError.code is typed SdkErrorCode. Both are exported from @north-light/sdk/errors alongside errorCodes and ErrorCode. That subpath also exports mapError(error), which turns a plain APIError into its status class below, for test doubles that build errors by hand.
| Code | Means | What to do |
|---|---|---|
id_token_invalid | An ID token failed verification (signature, issuer, audience, expiry, missing or mismatched nonce), or a sign-in-only answer had no ID token. A JWKS that can't be fetched or is malformed is not this code: that error is thrown as is. | Restart the sign-in. |
oauth_exchange_mismatch | The wrong exchange call: exchangeCode on a sign-in-only answer, or exchangeIdentity on an answer that carries a connection. | Use the call that matches the scopes you requested (Many users). |
oauth_nonce_missing | exchangeIdentity was called without the nonce from authorizeUrl. | Save the nonce in the visitor's session and pass it back. |
oauth_state_mismatch | The callback's state was missing or differed from the saved one. | Refuse the callback. |
not_connected | oauth.client(userId) or a refresh found no stored connection for that person. | Show "sign in again". |
refusal_handler_reentry | An onRefusal handler called oauth.client (or a refresh) for the person whose connection it is handling. | Don't call the runtime for refusal.userId inside the handler. |
unauthorized (401, AuthenticationError) | The directory refused your app's client assertion (invalid_client) on exchangeCode, a refresh or revoke, including a refresh inside a client from oauth.client(userId). The message names the key id (kid) you signed with and asks whether that key's public JWK is registered. details is {oauth_error: 'invalid_client', kid}, or key_thumbprint instead of kid for a key with no kid. | Check that the key's public JWK is on the app's Sign-in tab (Manage your app). |
run_idle, waiting_on_user | runs.parse: the run stopped without submitting, or waits on a question and you passed no onQuestion. | See Typed results. |
request_timeout, deadline_exceeded | A timeout, or the call's overall deadline passed. | Retry later, or raise the budget. |
A refused stored connection raises the directory's AuthenticationError, and refusalOf(error) returns the Refusal. See Connections.
Classes and mappings
Every class extends APIError, which carries status, code, message, details and headers.
| Condition | Class |
|---|---|
| SDK configuration error | CrouterError with status: 0, code: 'crouter_error' |
| The runtime does not serve the contract level the SDK requires, or names none | RuntimeVersionError, status: 426, code: 'runtime_version_unsupported'. See below. |
| HTTP 400 | BadRequestError |
| HTTP 401 | AuthenticationError |
| HTTP 403 | PermissionDeniedError |
| HTTP 404 | NotFoundError |
| HTTP 409 | ConflictError |
| HTTP 413, 422 | UnprocessableEntityError |
| HTTP 429 | RateLimitError |
Code deadline_exceeded | DeadlineExceededError (extends APIConnectionTimeoutError; status 504; reason elapsed or retry_after, deadlineAt, cause) |
HTTP 504 or code request_timeout | APIConnectionTimeoutError (extends APIConnectionError) |
| Other HTTP ≥ 500 | InternalServerError |
A transport failure, or code transport_error | APIConnectionError |
Code request_aborted | APIUserAbortError |
| Any other response | APIError |
The subclasses add no fields. status and code on the base class decide everything there is to branch on. They exist so that catch (error) { if (error instanceof NotFoundError) … } works.
import {ConflictError, NotFoundError, APIConnectionError} from '@north-light/sdk';
try {
await runtime.runs.get(runId);
} catch (error) {
if (error instanceof NotFoundError) return showRunGone();
if (error instanceof APIConnectionError) return showTryAgain();
throw error;
}426 runtime_version_unsupported
The SDK sends Crouter-Required-Contract and a frozen Crouter-Minimum-Runtime-Version with each call. If the runtime does not serve the contract level the SDK requires, the hosting router or the runtime refuses it before handling: nothing was accepted and no idempotency key was recorded. The error is a RuntimeVersionError: requiredContract and runtimeContract (or null) name both levels, sdkVersion and runtimeVersion stay for logging, and it is never retryable. A level is MAJOR.MINOR; the runtime satisfies it when it serves the same major and a minor at or above. A runtime that predates the contract level handles the request first, and the SDK then raises after.
In practice it means your SDK is newer than the runtimes, which can happen briefly after an SDK release. Install the previous version or wait, and tell support@northlight.ai. See the release notes.
The error envelope
Northlight's app-facing HTTP endpoints answer errors with one body shape. Your app's own endpoints can use the same shape with origin: 'app'. The SDK publishes the fixed code tuples:
import {errorCodes, type ErrorEnvelope} from '@north-light/sdk/errors';
const row = errorCodes.invalid_request;
// { type: 'invalid_request_error', status: 400, retryable: false }
const body: ErrorEnvelope = {
error: {
code: 'invalid_request',
message: 'Missing prompt',
type: row.type,
origin: 'app',
retryable: row.retryable,
request_id: 'req-123',
param: 'prompt',
},
};
// Send body with HTTP status row.status.The body is { error: { code, message, details?, type, param?, origin, retryable, retry_after_s?, reset_at?, request_id, run_id?, question_id?, user_action?, scope? } }.
originisdirectory,router,runtime,providerorapp.typeisinvalid_request_error,authentication_error,permission_error,rate_limit_errororserver_error.- Status belongs only in the HTTP status line, not in the body.
- Send
Retry-Afterwhenretry_after_sis set. Keeprequest_idin the body and echo it as a request-id response header. Do not expose stack traces in a 5xx message.
errorCodes is keyed by code, and each row gives that code's fixed { type, status, retryable }. An unsatisfiable byte range is range_not_satisfiable (416), an upstream model failure is upstream_model_unavailable (503, retryable), and an upstream vendor sign-in refusal is upstream_refused (502). If no existing code fits an app-specific failure, declare an app code with a consistent type and status and document it in your app's API description.
Not every error has a fixed row. provider_error carries the provider's varying 4xx status and is not in errorCodes. stream_gap, stream_dropped and stream_error are stream protocol codes, not ordinary refusals. runtime_provisioning, grant_removed, grant_suspended, refresh_token_expired and refresh_token_reused are raised by the SDK for directory token responses (Connections).
output_parsed from runs.parse is non-null on a result. A Zod schema supplies its inferred output type; a JSON Schema literal makes it unknown.
Retries
maxRetries defaults to 2, with exponential backoff. It applies to connection errors, and to HTTP 429 and 5xx responses on GET, HEAD and DELETE only. Override it per request with {maxRetries: 0} or {maxRetries: 5}.
POST and PATCH are not retried automatically, with two exceptions: runs.start and runs.message. A mutation whose response was interrupted may already have been applied, and replaying it would start a second run or deliver a second message. Those two carry an Idempotency-Key header: the SDK generates one key per call (or uses the idempotencyKey option) and reuses it on every retry of that call. The runtime records the key in the same transaction that accepts the request and holds it for 24 hours, so a retry after a lost response returns the original result and delivers once. Reusing a key with a different body is refused with 409 idempotency_conflict. Both calls retry transport failures and retryable refusals. A key the SDK generates does not survive a restart of your process: to dedupe across restarts, derive idempotencyKey from your own id.
A call with budgetMs (or deadlineAt) has one deadline over all attempts and waits, a server's Retry-After included. A wait longer than the time left rejects at once with DeadlineExceededError instead of sleeping. Without a deadline, a keyed send waits out any retry_after_s.
A 503 service_unavailable (retryable: true, origin: 'runtime') means the runtime could not confirm the grant the access token names. Nothing was refused and nothing ran. The SDK retries GET, runs.start and runs.message on it; on any other call, retry it yourself. Never treat it as a refused connection.