NorthlightSDK

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.

CodeMeansWhat to do
id_token_invalidAn 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_mismatchThe 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_missingexchangeIdentity was called without the nonce from authorizeUrl.Save the nonce in the visitor's session and pass it back.
oauth_state_mismatchThe callback's state was missing or differed from the saved one.Refuse the callback.
not_connectedoauth.client(userId) or a refresh found no stored connection for that person.Show "sign in again".
refusal_handler_reentryAn 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_userruns.parse: the run stopped without submitting, or waits on a question and you passed no onQuestion.See Typed results.
request_timeout, deadline_exceededA 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.

ConditionClass
SDK configuration errorCrouterError with status: 0, code: 'crouter_error'
The runtime does not serve the contract level the SDK requires, or names noneRuntimeVersionError, status: 426, code: 'runtime_version_unsupported'. See below.
HTTP 400BadRequestError
HTTP 401AuthenticationError
HTTP 403PermissionDeniedError
HTTP 404NotFoundError
HTTP 409ConflictError
HTTP 413, 422UnprocessableEntityError
HTTP 429RateLimitError
Code deadline_exceededDeadlineExceededError (extends APIConnectionTimeoutError; status 504; reason elapsed or retry_after, deadlineAt, cause)
HTTP 504 or code request_timeoutAPIConnectionTimeoutError (extends APIConnectionError)
Other HTTP ≥ 500InternalServerError
A transport failure, or code transport_errorAPIConnectionError
Code request_abortedAPIUserAbortError
Any other responseAPIError

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? } }.

  • origin is directory, router, runtime, provider or app.
  • type is invalid_request_error, authentication_error, permission_error, rate_limit_error or server_error.
  • Status belongs only in the HTTP status line, not in the body.
  • Send Retry-After when retry_after_s is set. Keep request_id in 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.

On this page