Errors
Every error the Nodaro TypeScript SDK throws, with its HTTP status, code and fields, and what to do about credits, rate limits, conflicts and failed jobs.
Every error the Nodaro SDK throws for an API answer is an instance of NodaroError or one of its subclasses. A request that fails with an error status throws the subclass that matches the status. The run-and-wait helpers throw their own subclasses when a job fails, times out or is stopped. Catch the specific classes first and NodaroError last.
Every error class
| Class | Status | code | Extra fields | Thrown when |
|---|---|---|---|---|
NodaroError | Any | The server's code | The base class, and any error without a more specific class | |
UnauthorizedError | 401 | unauthorized | The token is missing, expired or invalid | |
ForbiddenError | 403 | The server's code, else forbidden | missingScope? | Permission is denied, or an OAuth token lacks a scope |
ModelUnavailableError | 403 | model_not_available or model_disabled | The model you chose is not offered, or an admin switched it off | |
ImportRefusedError | 403 | imports_blocked, import_source_disabled, link_imports_disabled or a rights_* code | An import of a link was refused before anything was fetched | |
NotFoundError | 404 | The server's code, else not_found | The item does not exist, or you cannot see it | |
RateLimitedError | 429 | The server's code, else rate_limited | Too many requests | |
InsufficientCreditsError | 402 | insufficient_credits, or another 402 code | required?, available? | The account cannot pay for the run |
CreditCapExceededError | 402 | budget_exceeded, member_cap_exceeded or user_allowance_exceeded | required?, available? | A spending cap someone else set refused the run |
StorageExceededError | 413 | storage_exceeded | limitBytes?, reason? | What you sent does not fit: a full storage, or one file over the limit |
WorkflowConflictError | 409 | workflow_conflict or production_busy | currentUpdatedAt?, currentVersion?, currentRecord? | Someone else changed the item first |
AlreadyRunningError | 409 | already_running | executionId | The run you asked to start or continue is still running |
AppRunVersionChangedError | 409 | app_run_version_changed | runVersion?, appVersion? | An app run's Retry was refused because the app changed version |
JobBlockedError | 422 | job_blocked | The deployment's content policy refused the request | |
StudioOpError | 4xx | The server's code | opIndex | One operation in a Studio batch was refused |
JobFailedError | 0 | job_failed | jobId, jobStatus | A job you were waiting for failed or was cancelled |
JobTimeoutError | 0 | job_timeout | jobId, timeoutMs | A job did not finish within maxMs |
JobAbortedError | 0 | job_aborted | jobId? | Your AbortSignal fired while waiting |
JobHeldError | 0 | job_held | jobId | A job is held for human review |
StudioPreviewUnavailable | 0 | studio_preview_unavailable | The deployment cannot preview a Studio batch | |
StudioPreviewAppliedError | 0 | studio_preview_applied | applied | A Studio batch you asked to preview was applied |
Every class has four fields: message, a readable sentence; code, a stable string you can compare; status, the HTTP status; and details. A status of 0 means the error did not come from an HTTP answer. For example, JobTimeoutError is thrown by the SDK's own polling loop.
details holds every field of the error envelope other than code and message, as the server sent it. Examples are a trend refusal's slot, a publish refusal's reasons and a validation error's issues. It is {} when the envelope had nothing else, and for an error the SDK made itself.
try {
await client.trends.run(slug, request)
} catch (err) {
if (err instanceof NodaroError && err.code === "missing_slot") askFor(err.details.slot as string)
}The constructor is new NodaroError(message, code, status, details?).
Some statuses map to one class, whatever code the server sent. A 403 becomes ForbiddenError, except a refused model, which becomes ModelUnavailableError, and a refused import of a link, which becomes ImportRefusedError. A 404 becomes NotFoundError, a 429 becomes RateLimitedError, and every 413 becomes StorageExceededError. ForbiddenError, NotFoundError and RateLimitedError keep the server's own code, such as node_not_available, continuation_not_found or rate_limit_exceeded. They fall back to forbidden, not_found and rate_limited only when the server sent no code. A 409 becomes WorkflowConflictError only with the code workflow_conflict or production_busy. A 409 already_running that names an executionId becomes AlreadyRunningError, and a 409 app_run_version_changed becomes AppRunVersionChangedError. Errors with any other status, such as 400 or 503, and a 409 with any other code, arrive as NodaroError with the server's own code.
Catch errors in order
import {
ForbiddenError,
InsufficientCreditsError,
ModelUnavailableError,
NodaroError,
NotFoundError,
RateLimitedError,
StorageExceededError,
UnauthorizedError,
} from "@nodaro/sdk"
try {
await client.workflows.run(workflowId)
} catch (err) {
if (err instanceof UnauthorizedError) {
redirectToLogin()
} else if (err instanceof ModelUnavailableError) {
showError("This model is not available. Choose another model.")
} else if (err instanceof ForbiddenError) {
if (err.missingScope) requestAdditionalScopes([err.missingScope])
else showError("You do not have permission to do this.")
} else if (err instanceof InsufficientCreditsError) {
showCreditPaywall({ required: err.required, available: err.available })
} else if (err instanceof RateLimitedError) {
await retryWithBackoff()
} else if (err instanceof StorageExceededError) {
if (err.reason === "file_too_large") showError("This file is too large.")
else showError(`Storage limit of ${err.limitBytes} bytes reached.`)
} else if (err instanceof NotFoundError) {
showError("Not found.")
} else if (err instanceof NodaroError) {
console.error(`API error ${err.status} (${err.code}): ${err.message}`)
} else {
throw err // a network failure or a timeout, not an API answer
}
}A request that exceeds the client's timeoutMs, and a network failure, reject with the runtime's own error, such as an AbortError or a TypeError. These are not NodaroError instances.
Authentication and permission
UnauthorizedError
HTTP 401. The token is missing, has expired, or is invalid. Get a new token, or send the user through sign-in again, then retry.
ForbiddenError
HTTP 403. The caller may not do this. When an OAuth token was not granted a scope the endpoint needs, missingScope names that scope, for example workflows:execute. Ask the user to approve it, then retry with the new token. See Scopes and missing permissions.
Other reasons include an edition that does not offer the feature and a role that is too low. code is the server's own code, for example insufficient_scope or node_not_available, so you can branch on it. It is forbidden only when the server sent no code.
ModelUnavailableError
HTTP 403 that refuses the model, not the caller. Nodaro answers it before any credits are held. Choose another model: a retry, a top-up or a wider scope changes nothing.
code | Refusal |
|---|---|
model_not_available | This deployment does not offer the model. The model is outside its edition or surface, or its operator hid it. |
model_disabled | An admin switched the model off. |
ModelUnavailableError extends ForbiddenError, so an existing instanceof ForbiddenError check still catches it. Check instanceof ModelUnavailableError first to tell the two apart. Read code to show your own sentence, not the English message. MODEL_UNAVAILABLE_CODES, a ReadonlySet<ModelUnavailableCode>, lists the two codes. The error does not name the model: the refused model is the provider of your request, or one of its models for a request that runs several. A 403 with insufficient_scope, with any other code or with no code stays a plain ForbiddenError.
The constructor is new ModelUnavailableError(code, message?). The ForbiddenError constructor is new ForbiddenError(message?, missingScope?, code?).
ImportRefusedError
HTTP 403 that refuses an import of a link, before anything was fetched: media.downloadVideo(), uploads.importUrl() and the audio imports. A measure on the account, the content rights check or the deployment's site policy said no. See Content rights and Repeat infringers.
code | Refusal |
|---|---|
imports_blocked | Importing links is blocked on the account, or the account is closed, under the deployment's repeat-infringer policy. The message says until when: a date in UTC, or until an administrator lifts the block. |
import_source_disabled | The deployment does not allow imports from the link's site. The message names no site. |
link_imports_disabled | The deployment turned imports from links to other sites off. A link to another site is refused, so upload the file instead. A file of its own storage is not refused. |
rights_confirmation_required | The deployment requires a confirmation, and none was sent. |
rights_authorization_required | The deployment requires a confirmation, the import came through an API key, a connected app or an AI agent, and the account holds no standing authorization. The person authorizes in their own session, with contentRights.authorize(). |
rights_confirmation_not_found | No confirmation has that id. |
rights_confirmation_mismatch | The confirmation is for another link or another account. |
rights_confirmation_stale | The declaration changed. Read it again and confirm again. |
rights_confirmation_expired | The confirmation is older than one hour. |
rights_confirmation_used | An import was already made under the confirmation. An import that failed after its fetch started counts too. |
For a rights_confirmation_* code, call client.contentRights.confirm({ url, declarationVersion }) again and import again with the new id. ImportRefusedError extends ForbiddenError, so an existing instanceof ForbiddenError check still catches it. Check instanceof ImportRefusedError first, and read code to show your own sentence. IMPORT_REFUSAL_CODES, a ReadonlySet<ImportRefusalCode>, lists the codes. The constructor is new ImportRefusedError(code, message?).
NotFoundError
HTTP 404. The item does not exist, or it is not visible to this caller. Nodaro answers the same way in both cases, so an id never reveals whether something you cannot see exists. A resource that exists only on Nodaro Cloud, such as Studio productions or Recast, also answers 404 on a self-hosted install.
code is the server's own code, for example continuation_not_found. It is not_found when the server sent no code, as for a route that does not exist. The constructor is new NotFoundError(message?, code?).
Credits, storage and limits
InsufficientCreditsError
HTTP 402. The account cannot pay for the run, so nothing started. required is the number of credits the run needs, and available is the number the account has, or, for a cap, what the cap has left. Both are set by Nodaro Cloud, but the type marks them optional. code is the refusal's own code: insufficient_credits for your own balance, and when the server sent no code, or another 402 code such as free_video_limit or insufficient_app_credits.
try {
await client.nodes.runAndWait("generate-video", params)
} catch (err) {
if (err instanceof InsufficientCreditsError) {
console.log(`Need ${err.required} credits, have ${err.available}`)
}
}Read the balance before a run with client.credits.balance(). See Credits.
CreditCapExceededError
HTTP 402 from a spending cap someone else set, not from your own balance. Topping up does not help: whoever set the cap has to raise it.
code | Cap |
|---|---|
budget_exceeded | The workspace's budget |
member_cap_exceeded | Your credit cap in that workspace |
user_allowance_exceeded | The deployment's per-user allowance |
CreditCapExceededError extends InsufficientCreditsError, so an existing instanceof InsufficientCreditsError check still catches it. Check instanceof CreditCapExceededError first to tell the two apart. CREDIT_CAP_CODES, a ReadonlySet<CreditCapCode>, lists the three codes.
StorageExceededError
HTTP 413. What you sent does not fit. Every 413 arrives as this class, whatever the route, and reason says which kind it is:
reason | Meaning |
|---|---|
storage_limit_exceeded | The account's storage is full. limitBytes is the limit. Delete media you no longer need, then retry. |
file_too_large | One file is over the size limit. The storage is not full. Send a smaller file. |
payload_too_large, post_too_large, duration_exceeded | The request, the post or the media length is over a limit. The storage is not full. |
reason is undefined when the answer carried no code. Uploads and copies into your storage, such as a community clone, throw this error. The constructor is new StorageExceededError(message?, limitBytes?, reason?).
RateLimitedError
HTTP 429. You sent too many requests. Wait, then retry with a growing pause: for example 2 seconds, then 4, then 8. Stop after a few attempts. See Rate limits.
code is the server's own code, for example rate_limit_exceeded, or rate_limited when the server sent none. The constructor is new RateLimitedError(message?, code?).
Waiting for jobs
client.nodes.runAndWait(), client.nodes.runMany() and the ...AndWait helpers of other resources poll a job until it ends. They throw these errors while they wait.
JobFailedError
The job ended with the status failed or cancelled. jobStatus says which, jobId names the job, and message carries the job's own error message. Read the full job with client.jobs.get(err.jobId): its error_hint explains a safety or policy block.
JobTimeoutError
The job did not reach an end state within maxMs, 15 minutes by default. The job is not cancelled. It usually still completes on the server and lands in your library. Fetch it later with client.jobs.get(err.jobId), or pass a larger maxMs for slow models. A job that the platform is recovering reports recovering: true in its status, and recovery can take tens of minutes.
JobAbortedError
Your AbortSignal fired. The SDK stops polling at once. The job is not cancelled. To stop it on the server and refund its reserved credits, call client.jobs.cancel(err.jobId).
JobHeldError
The job reached the status pending_review: a content policy of this deployment held the result for a person to review. The SDK stops waiting on the first poll that sees this status. The job is not cancelled, and its credits stay reserved during the review. Do not run the request again, because a duplicate would be held too.
Check the job later with client.jobs.get(err.jobId). It resolves to completed when the reviewer approves it, to failed when the reviewer rejects it, or to cancelled if you cancel it. A rejected job carries error_hint.kind set to policy-block and a reason you can show as it is. This error occurs only on deployments that register a job policy.
JobBlockedError
HTTP 422 with the code job_blocked. A job policy or content guard of this deployment refused the request before it ran. No job was created and nothing was charged. message is written for your users, so show it as it is. Do not retry the same request. This error occurs only on deployments that register a job policy.
Concurrent changes
WorkflowConflictError
HTTP 409. You made a conditional change, and someone else changed the item first. It arrives with one of two codes:
workflow_conflict: aclient.workflows.update()withexpectedVersionorexpectedUpdatedAtdid not match the stored workflow.production_busy: a Studio production kept changing while the server applied your change, and the server stopped retrying.
The remedy is the same for both: read the item again, apply your change to the fresh copy, and send it again. When the server includes it, currentRecord holds the current workflow, so you can merge without another read.
import { WorkflowConflictError } from "@nodaro/sdk"
try {
await client.workflows.update(id, { settings, expectedVersion: loadedVersion })
} catch (err) {
if (err instanceof WorkflowConflictError && err.currentRecord) {
const merged = mergeSettings(err.currentRecord.settings, settings)
await client.workflows.update(id, { settings: merged, expectedVersion: err.currentVersion })
} else {
throw err
}
}AlreadyRunningError
HTTP 409 with the code already_running. The run you asked to start or continue is still running, and executionId names it. Follow that run with client.executions.get(executionId) instead of starting it again. client.workflows.run() throws it while a run of the workflow is under way. client.apps.resume() throws it while a Retry of the run is pending.
The class is chosen when the 409 carries an executionId. A 409 that names nothing stays a plain NodaroError.
AppRunVersionChangedError
HTTP 409 with the code app_run_version_changed. A Retry of a failed app run with client.apps.resume() was refused, because the app is on another version than the one the run ran. Nothing was written or billed.
runVersion: the version the run ran.appVersion: the version you are on, that is theversionyou sent, or else the app's latest.
Both fields are undefined when the answer does not carry them. Start the run over with client.apps.run() to run the app's current version. To continue on the run's own version, call resume again with { version: err.runVersion }.
Locations and objects use their own conflict code, concurrent_modification, which arrives as a plain NodaroError. Handle it the same way: read the item again, merge, and retry.
Studio batches
These three classes belong to Studio productions.
StudioOpError: a batch of operations was refused, andopIndexis the zero-based position of the operation that caused it. Nothing in the batch was written. Fix that operation and send the whole batch again.StudioPreviewUnavailable: you asked for a preview withdryRun: true, and this deployment cannot give one. Your batch was not sent. Tell the user a preview is not available, and do not apply the batch without asking.StudioPreviewAppliedError: you asked for a preview, and the batch was applied anyway. Treatapplied.productionandapplied.versionas the current state. Do not send the batch again. Whenappliedisundefined, read the production again before you decide anything.
Codes you may see on NodaroError
These codes arrive on a plain NodaroError. Compare err.code to handle them.
| Status | Code | Where | Meaning |
|---|---|---|---|
| 400 | validation_error | Many methods | A field is missing or invalid. message names it. |
| 400 | no_valid_inputs | client.reduce.run() | Every input was empty. |
| 400 | invalid_edl | client.edit.applyEdl() | The edit decision list failed validation. |
| 400 | limit_reached | client.developerApps.create() | You already have the maximum number of apps. |
| 400 | locked_field | client.apps.run() | An override tried to change a destination, such as a webhook URL. |
| 409 | name_taken | Characters, organizations | The name or slug is already in use. |
| 409 | concurrent_modification | Locations, objects | The item changed since you read it. |
| 410 | voice_cloning_retired | client.voices.createClone() | Voice cloning is no longer offered. Design a voice instead. |
| 400 | quote_unsupported | client.nodes.quote() | The platform cannot quote this node type. The message lists the types it can quote. |
| 400, 502 | advanced_mode_unavailable | client.llm.structured() | The model's own provider cannot take Advanced-mode requests right now. Turn Advanced mode off to run the model. |
| 409 | job_cancelled | Metered LLM requests, such as client.llm.structured() | The job was cancelled after its credits were held, but before its call was sent. Nothing is charged. Do not retry it as it is: the job was cancelled on purpose. |
| 503 | job_start_failed | Metered LLM requests | The server could not record that the call is starting, so it did not send it. Nothing is charged. Retry it. |
| 503 | provider_unavailable | client.llm.structuredJob() | The instance cannot run this model. Do not retry. |
| 503 | feature_disabled | client.copilot | The feature is switched off on this deployment. |
| 503 | nodaro_connection_required | client.edit.editPlan() | A self-hosted install needs a Nodaro Cloud connection for this. |
Each reference page lists the codes of its own methods. The REST API errors page lists every code the API can send.
Retry safely
- Retry reads freely. A
getorlisthas no side effects. - Do not repeat a paid request blindly. A timed-out request may still have started a run. Before you retry a generation, pass an idempotency key:
client.nodes.run(type, params, { idempotencyKey })andrunAndWaitaccept one. Reuse the same key when you retry the same request, and the platform returns the first run instead of starting and charging a second one. - Studio and Recast use their own retry tokens:
clientRequestIdon Studio methods andrequestIdonclient.recast.rescore(). - Retry 5xx answers with a pause. A custom
fetchincreateClientis a good place for that logic.
throwFromResponse(status, body)
throwFromResponse(status: number, body: {
error?: { code?: string; message?: string; [key: string]: unknown }
}): neverMaps an HTTP status and a Nodaro error body to the matching error class and throws it. The SDK uses it for every response, and it is exported for custom transports that call the API without the client.
Prop
Type
import { throwFromResponse } from "@nodaro/sdk"
throwFromResponse(403, {
error: { code: "insufficient_scope", message: "Missing scope", missingScope: "workflows:execute" },
})
// throws a ForbiddenError whose missingScope is "workflows:execute"Most routes answer with the { error: { code, message } } envelope. A few older routes answer with other shapes, and the SDK reads those too, so their errors carry a real code and message:
| Body | code | message |
|---|---|---|
{ error: "<code>", message? }, for example from character training | The string | message, or the code when there is none |
{ error: "<code>", error_description }, from OAuth | The string | error_description |
{ message, issues: [...] }, a validation answer with HTTP 400 | validation_error | message |
{ statusCode, error: "<reason phrase>", message }, for example a route the server does not have | internal_error | message |
At HTTP 500 and above, the SDK reads only the code from these shapes, never their text. Such an error's message is its code, or "Request failed". The envelope's own message is read at every status.
The error class still follows the status: a 404 is a NotFoundError whatever the body says. A body with no readable text keeps the class's default message, such as "Not found". An InsufficientCreditsError reads required and available from the envelope, or the required and balance fields the server sends beside it. For a per-user allowance, available reads remaining.
Frequently asked questions
How do I catch Nodaro API errors in TypeScript?
Wrap the call in try/catch and test the error with instanceof, from the most specific class to NodaroError last. Every class is exported from @nodaro/sdk.
What happens when the account has too few credits?
The call throws InsufficientCreditsError with HTTP status 402 before any work starts. Its required and available fields tell you how many credits the run needs and how many the account has.
Does JobTimeoutError cancel the job?
No. The job keeps running and usually still completes. Fetch it later with client.jobs.get(jobId), or raise maxMs for slow models.
Should I retry after a RateLimitedError?
Yes, after a pause. Wait a few seconds, double the wait on each new 429, and stop after a few attempts.
Which code does a ForbiddenError carry?
The server's own code, for example insufficient_scope or node_not_available, so you can branch on it. The code is forbidden only when the server sent none. A refused model arrives as ModelUnavailableError, and a refused import of a link as ImportRefusedError.
Related
TypeScript SDK
Jobs and executions
Run nodes
Errors
Rate limits
Last updated on
Authentication
Choose how the Nodaro SDK authenticates, with StaticTokenAuth, CallbackAuth or supabaseAuth, and share one browser login across your subdomains.
Workflows and projects
Create, update, share, export, import and run Nodaro workflows from TypeScript with client.workflows, and organize them in projects with client.projects.