# 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.

Source: https://nodaro.ai/docs/developers/sdk/errors

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 | `forbidden` | `missingScope?` | Permission is denied, or an OAuth token lacks a scope |
| `NotFoundError` | 404 | `not_found` | | The item does not exist, or you cannot see it |
| `RateLimitedError` | 429 | `rate_limited` | | Too many requests |
| `InsufficientCreditsError` | 402 | `insufficient_credits` | `required?`, `available?` | The account cannot pay for the run |
| `StorageExceededError` | 413 | `storage_exceeded` | `limitBytes?` | The account's storage is full |
| `WorkflowConflictError` | 409 | `workflow_conflict` or `production_busy` | `currentUpdatedAt?`, `currentVersion?`, `currentRecord?` | Someone else changed the item first |
| `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 three fields: `message`, a readable sentence; `code`, a stable string you can compare; and `status`, the HTTP status. 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.

Some statuses map to one class, whatever code the server sent. A 403 becomes `ForbiddenError` with `code` set to `forbidden`, and a 404 becomes `NotFoundError`. Read `message` for the server's reason in those cases. Errors with any other status, such as 400, 409 or 503, arrive as `NodaroError` with the server's own `code`.

## Catch errors in order

```ts

ForbiddenError,
InsufficientCreditsError,
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 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) {
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](https://nodaro.ai/docs/developers/sdk/auth#scopes-and-missing-permissions).

Other reasons include an edition that does not offer the feature and a role that is too low. They all arrive with `code` set to `forbidden`, so show `message` to explain which.

### 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.

## 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. Both are set by Nodaro Cloud, but the type marks them optional.

```ts
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()`](https://nodaro.ai/docs/developers/sdk/models-and-credits). See [Credits](https://nodaro.ai/docs/concepts/credits).

### StorageExceededError

HTTP 413. The account has reached its storage limit, and `limitBytes` is that limit. Uploads and copies into your storage, such as a community clone, throw it. Delete media you no longer need, then retry.

### 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](https://nodaro.ai/docs/developers/api/rate-limits).

## 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 content policy 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`: a [`client.workflows.update()`](https://nodaro.ai/docs/developers/sdk/workflows) with `expectedVersion` or `expectedUpdatedAt` did 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.

```ts

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
}
}
```

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](https://nodaro.ai/docs/developers/sdk/studio).

- **`StudioOpError`**: a batch of operations was refused, and `opIndex` is 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 with `dryRun: 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. Treat `applied.production` and `applied.version` as the current state. Do not send the batch again. When `applied` is `undefined`, 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. |
| 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](https://nodaro.ai/docs/developers/api/errors) page lists every code the API can send.

## Retry safely

- **Retry reads freely.** A `get` or `list` has 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 })` and `runAndWait` accept 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: `clientRequestId` on Studio methods and `requestId` on `client.recast.rescore()`.
- **Retry 5xx answers with a pause.** A custom `fetch` in [`createClient`](https://nodaro.ai/docs/developers/sdk/client#timeouts-and-a-custom-fetch) is a good place for that logic.

## throwFromResponse(status, body)

```ts
throwFromResponse(status: number, body: {
error?: { code?: string; message?: string; [key: string]: unknown }
}): never
```

Maps 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.

<TypeTable
type={{
status: { type: 'number', required: true, description: "The HTTP status of the response." },
body: { type: '{ error?: { code?, message?, ... } }', required: true, description: "The parsed JSON body. Extra fields such as missingScope, required, available, limitBytes and opIndex fill the matching error fields." },
}}
/>

```ts

throwFromResponse(403, {
error: { code: "insufficient_scope", message: "Missing scope", missingScope: "workflows:execute" },
})
// throws a ForbiddenError whose missingScope is "workflows:execute"
```

## 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.

### Why does a 403 error not tell me the exact reason code?

Every 403 arrives as ForbiddenError with code forbidden. Read its message for the reason, and missingScope when an OAuth scope is missing.
