> For the complete documentation index, see [llms.txt](https://docs.durohub.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.durohub.com/advanced-topics/error-handling.md).

# Error Handling

Learn how to handle errors and edge cases in the Duro API.

### Error Types

The API returns different types of errors:

* Validation errors
* Authentication errors (see [Authentication Errors](#authentication-errors) — these use a different envelope)
* Authorization errors
* Entitlement (subscription plan) errors
* Rate limiting errors
* Server errors

### Error Format

Errors arrive in the standard GraphQL `errors` array. `extensions.code` is the stable, programmatic key — branch on it rather than on `message`, which may be reworded.

```json
{
  "errors": [
    {
      "message": "Unauthorized",
      "path": ["user"],
      "extensions": {
        "code": "UNAUTHENTICATED",
        "originalError": { "message": "Unauthorized", "statusCode": 401 },
        "service": "foundation"
      }
    }
  ]
}
```

There are two shapes to be aware of, and `code` is the only key present in both:

* **Framework errors** — authentication and authorization failures raised before your operation runs (`UNAUTHENTICATED`, `FORBIDDEN`). The HTTP status is nested under `extensions.originalError.statusCode`, as above.
* **Domain errors** — business-rule violations raised by the operation itself. Every code documented on this page is one of these. They carry `statusCode` directly on `extensions`, with no `originalError`:

```json
{
  "errors": [
    {
      "message": "Seat limit reached (25/25). Upgrade your plan or remove a member to add more.",
      "path": ["organization", "addMember"],
      "extensions": {
        "code": "SEAT_LIMIT_REACHED",
        "statusCode": 403,
        "service": "foundation"
      }
    }
  ]
}
```

`service` names the subgraph that raised the error. A few domain errors carry extra keys; those are called out alongside the code below.

### Common Error Scenarios

```json
// Rate Limiting Error
{
  "errors": [
    {
      "message": "Rate limit exceeded",
      "extensions": {
        "code": "RATE_LIMITED",
        "retryAfter": 60
      }
    }
  ]
}
```

### Authentication Errors

Errors from the `/auth/*` account APIs — signup, sign-in, sessions, password reset, SSO — do **not** use the GraphQL envelope. They are REST endpoints, and they return a flat JSON body:

```json
{
  "code": "AUTH_INVALID_CREDENTIALS",
  "message": "Incorrect email or password"
}
```

The `code` is stable; the `message` is copy and may be reworded. Branch on `code`.

| Code                    | Status | Meaning                                                                                     |
| ----------------------- | ------ | ------------------------------------------------------------------------------------------- |
| `AUTH_ORIGIN_REJECTED`  | 403    | Missing or non-allowlisted `Origin` header. Browsers send it automatically; `curl` does not |
| `AUTH_TOKEN_INVALID`    | 400    | A verification or reset token that is unknown, expired, or already used                     |
| `AUTH_RATE_LIMITED`     | 429    | Well past the route's budget                                                                |
| `AUTH_REQUEST_REJECTED` | 4xx    | Malformed request — a missing field, a bad email shape, or a rejected password              |
| `AUTH_UNAVAILABLE`      | 503    | Temporary failure. Retry                                                                    |

Some codes deliberately cover more than one situation, so that the error surface cannot be used to work out who has a Duro account. See [Creating an Account](/account-management/creating-an-account.md).

### Access Errors

Authenticating identifies *who* you are. A separate check decides whether that identity may use Duro at all — see [`user.me.permitted`](/getting-started/current-user.md#fields). A valid token whose identity is not permitted is refused at every data operation with `ACCESS_NOT_PERMITTED`. The token itself is fine; the account is not yet allowed in.

| Code                   | Status | Meaning                                                                                                                                                                                                                         | How to resolve                                                                                                                                                                                             |
| ---------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_NOT_PERMITTED` | 403    | The identity is authenticated but not permitted: it is archived, or it has no organization membership, no pending invitation, no allowlisted email or domain, and no pending organization-creation grant. Nothing was returned. | Query `user.me { permitted }` to confirm. The account needs to be invited to an organization, or its email or domain approved by Duro. Once it is permitted the same request succeeds with the same token. |

```json
{
  "errors": [
    {
      "message": "Your access to Duro is not yet enabled.",
      "path": ["component", "list"],
      "extensions": {
        "code": "ACCESS_NOT_PERMITTED",
        "statusCode": 403,
        "service": "foundation"
      }
    }
  ]
}
```

{% hint style="info" %}
`ACCESS_NOT_PERMITTED` is distinct from `FORBIDDEN`. `FORBIDDEN` means the identity is in Duro but lacks permission for a specific resource — see [Role-Based Access Control](/advanced-topics/rbac.md). `ACCESS_NOT_PERMITTED` means the identity is not allowed into Duro at all. `user.me` itself always resolves, so a client can read `permitted` and show a clear message instead of retrying.
{% endhint %}

### Validation Errors

Validation errors are returned when a mutation violates a business rule. The `extensions.code` identifies the specific rule so you can handle it programmatically.

| Code                          | Operation        | Meaning                                                                                                                                                                                                                    |
| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NRM_NOT_ALLOWED_ON_OBSOLETE` | Component update | A component cannot be marked Not Revision Managed (NRM) while it is Obsolete. Change the status away from Obsolete before enabling NRM. See [Not Revision Managed](/core-concepts/components.md#not-revision-managed-nrm). |

```json
// Marking an Obsolete component as NRM
{
  "errors": [
    {
      "message": "Not Revision Managed and Obsolete cannot be combined. Turn off Not Revision Managed before obsoleting this component.",
      "path": ["component", "update"],
      "extensions": {
        "code": "NRM_NOT_ALLOWED_ON_OBSOLETE",
        "statusCode": 400
      }
    }
  ]
}
```

### Item Write Errors

`item.create` and `item.update` write a component together with its BOM children and its documents in a single transaction — see [Item Mutations](/core-concepts/components.md#item-mutations-bom-and-documents-in-one-call). Because the whole call is one unit of work, an oversized request is refused up front rather than partially applied.

| Code                   | Operation                    | Meaning                                                                                                                       |
| ---------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `ITEM_WRITE_TOO_LARGE` | `item.create`, `item.update` | The request exceeds one of the write limits below. The check runs before any work starts, so nothing was written. HTTP `413`. |

The limits (per call unless stated):

| Limit                                                         | Maximum |
| ------------------------------------------------------------- | ------- |
| Components in one `inputs` array                              | 10      |
| `children` on one component                                   | 20      |
| `children` summed across the call                             | 200     |
| `documents` on one component                                  | 20      |
| `documents` summed across the call                            | 20      |
| `documentComponentIds` on one component                       | 20      |
| `removeDocumentLinkIds` on one component (`item.update` only) | 20      |

The `message` names the limit that was hit:

```json
{
  "errors": [
    {
      "message": "This call exceeds a write limit: 25 children on component \"ASM-0042\" (maximum 20)",
      "path": ["item", "update"],
      "extensions": {
        "code": "ITEM_WRITE_TOO_LARGE",
        "statusCode": 413
      }
    }
  ]
}
```

**How to resolve:** split the work into several calls, one per component rather than a large batch, and move a component's documents into a follow-up `item.update` if the component alone is still too large. Do **not** split a single component's `children` across calls — the list is desired-state, so a partial list unlinks the children left out of it. A parent with more than 20 children cannot be published through `item.*` at all: create it with no `children`, then send the full BOM through `assemblyMutation.updateBOM`, which has no such cap. Retrying the same request unchanged will not succeed.

### Change Order Type Errors

Selecting a change order [type](/core-concepts/change-orders.md#change-order-types) can produce validation errors when the requested type is not permitted, or when a `DCO` (Documentation Change Order) is asked to do something its revision-freeze forbids.

#### `CO_TYPE_NOT_ALLOWED`

Returned when a change order is given a `coType` that the chosen template does not allow. A template's allowed types are declared in its `co_types` list (schema version `1.1`). The resolved type — whether passed explicitly in the input, taken from the template's `defaultCoType`, or defaulted to `ECO` — must be a member of that list.

This applies both at creation (`changeOrders.create`) and when switching a draft change order's type or template with `changeOrders.submitDraft` (`coType` / `configId`) — see [Switching Type and Template in Draft](/core-concepts/change-orders.md#switching-type-and-template-in-draft). When both fields are passed to `submitDraft`, the type is validated against the **new** template.

```json
{
  "errors": [
    {
      "message": "Change order type \"MCO\" is not allowed by this template. Allowed types: [ECO, DCO].",
      "path": ["changeOrders", "create"],
      "extensions": {
        "code": "CO_TYPE_NOT_ALLOWED",
        "statusCode": 400
      }
    }
  ]
}
```

**How to resolve:**

* Query the template's allowed types before creating and pass a `coType` that is a member of `coTypes` (or omit `coType` to fall back to `defaultCoType`):

  ```graphql
  query {
    changeOrders {
      getTemplates { id name coTypes defaultCoType }
    }
  }
  ```
* Legacy (`1.0`) templates return `null` for `coTypes` and allow only `ECO`. To offer additional types, migrate the template to `version: "1.1"` — see the [Workflow YAML Reference](/library-configuration/change-order-workflow-reference.md#change-order-types).

{% hint style="info" %}
This check is enforced atomically against the template, so a concurrent edit that removes a type in the same instant cannot let a disallowed type slip through — you will consistently get `CO_TYPE_NOT_ALLOWED` rather than a change order that outlives its template's rules.
{% endhint %}

#### `DCO_STATUS_CHANGE_NOT_ALLOWED` / `DCO_REVISION_CHANGE_NOT_ALLOWED`

A change order of type `DCO` is **revision-frozen**: it never bumps a component's revision and cannot change a component's `status` or `revision`. These codes are raised whenever a DCO would result in such a change. If the change genuinely needs to alter a component's status or revision, use a non-`DCO` type. See [Change Order Types](/core-concepts/change-orders.md#change-order-types).

They surface in three situations:

* **Proposed change.** Proposing a status change or a revision change directly on a DCO item is rejected.
* **Baseline drift at submit.** The built-in [DCO baseline validation](/library-configuration/change-order-validations.md#dco-baseline-freeze) blocks the change order when any item's live `status` or `revision` has drifted from its last-released baseline — for example because a concurrent change order released a new revision of that item after it was added to the DCO.
* **Release-time abort at close.** The release path re-checks the baseline when the DCO closes and aborts with these codes if drift is detected then, or if a DCO item has no releasable baseline or its live component can't be found.

### Change Order Export Errors

A change order export package is built asynchronously by an export job. When the job fails, Duro classifies the failure and records it on the job as a `failureReason`. Some reasons are **retryable** (a transient file-service or delivery problem, for example) and some are **permanent** — retrying will fail the same way until the underlying data is fixed.

| `failureReason`                                    | Retryable | Meaning                                                                                                                                                                                                                          |
| -------------------------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DOCUMENT_UNAVAILABLE`                             | No        | A document attached to the change order could not be fetched — typically its file is missing or is tagged with the wrong organization. The job's `blockingDocument` identifies it. See [Documents](/core-concepts/documents.md). |
| `CHANGE_ORDER_UNAVAILABLE`                         | No        | The change order the job was created for could no longer be read when the package was built.                                                                                                                                     |
| `JOB_MALFORMED`                                    | No        | The job's stored parameters are invalid and cannot produce a package.                                                                                                                                                            |
| `FILE_SERVICE_UNAVAILABLE`, `FILE_SERVICE_TIMEOUT` | Yes       | The file service was unreachable or too slow while downloading documents.                                                                                                                                                        |
| `PACKAGING_FAILED`, `DELIVERY_FAILED`              | Yes       | The archive could not be assembled or uploaded.                                                                                                                                                                                  |
| `WORKFLOW_START_FAILED`, `TIMED_OUT`, `UNKNOWN`    | Yes       | The export workflow did not start, exceeded its time budget, or failed for an unclassified reason.                                                                                                                               |

Read `retryable` on the export job before offering a retry — it is derived from the reason above, and is `true` for jobs that failed before failure reasons were recorded. Both `retryable` and `blockingDocument` require `library.read` or `exports.create`.

```graphql
# Fields to select on the change order's latest export job
{
  id
  status
  failureReason
  retryable
  blockingDocument { id name }
}
```

#### `EXPORT_CO_PERMANENT_FAILURE`

Returned by `retryChangeOrderExport` when the job being retried failed for a permanent reason. `extensions.failureReason` carries the reason so you can tell the user what to fix.

```json
{
  "errors": [
    {
      "message": "This export failed permanently and cannot be retried until the underlying issue is resolved.",
      "path": ["changeOrders", "retryChangeOrderExport"],
      "extensions": {
        "code": "EXPORT_CO_PERMANENT_FAILURE",
        "failureReason": "DOCUMENT_UNAVAILABLE",
        "service": "exports"
      }
    }
  ]
}
```

**How to resolve:**

* Fix the underlying data first — re-upload or correct the document named by `blockingDocument`, or restore the change order.
* Then retry with `force: true`. The guard is skipped and a new export job is started regardless of the previous failure's classification:

  ```graphql
  mutation {
    changeOrders {
      retryChangeOrderExport(input: { changeOrderId: "...", force: true }) {
        id
        status
      }
    }
  }
  ```

{% hint style="warning" %}
`force` does not make the export succeed — it only bypasses the permanent-failure check. Retrying a `DOCUMENT_UNAVAILABLE` job with `force: true` before the document is fixed produces another job that fails with the same reason. Only pass it after you have resolved the cause reported in `failureReason`.
{% endhint %}

### Organization Creation Errors

Creating a new production organization is gated: the account must hold a valid, unused organization-creation grant issued to its verified email address. Self-serve creation without a grant is not yet available. When a create is refused, `extensions.code` identifies why so you can present a clear, distinguishable message. Query [`Me.canCreateOrg`](/getting-started/current-user.md#checking-whether-a-user-can-create-an-organization) beforehand to know whether to offer the action at all.

The codes below apply to the grant path. Creating a **sandbox** organization under an existing parent goes through a different set of checks — see [Sandbox Organization Errors](#sandbox-organization-errors).

All five are `403`.

| Code                             | Meaning                                                                                                                                                                                                                                                                                                                                                                | How to resolve                                                                                               |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `GRANT_NONE`                     | **The refusal you should expect in practice.** No currently claimable grant was found for the account. A grant that was never issued, has already been consumed, was revoked, has expired, or whose backing package is archived all collapse into this one code — the message is deliberately generic and never reveals whether a grant exists for some other account. | Request a grant from Duro before creating an organization.                                                   |
| `SELF_SERVE_NOT_AVAILABLE`       | The account has no claimable grant **but is on the self-serve allowlist** — it would be eligible to create its own organization, except self-serve creation has not shipped yet. It is the more specific refusal for accounts that would otherwise be permitted, not a broader one.                                                                                    | Request an organization-creation grant from Duro in the meantime.                                            |
| `GRANT_ALREADY_CONSUMED`         | **Race-only.** The grant was pending when it was read, but a concurrent create claimed it first. A grant that was already consumed before the request began returns `GRANT_NONE` instead.                                                                                                                                                                              | Treat as a lost race. Do not retry — the grant is spent. Request a new grant to create another organization. |
| `GRANT_REVOKED`                  | **Race-only.** The grant was revoked in the instant between being read and being claimed. A grant revoked before the request began returns `GRANT_NONE` instead.                                                                                                                                                                                                       | Contact Duro to have a new grant issued.                                                                     |
| `GRANT_BACKING_PACKAGE_ARCHIVED` | **Defensive guard; not reachable on the normal path.** Grants whose backing package is archived are filtered out before the claim, so they surface as `GRANT_NONE`.                                                                                                                                                                                                    | Contact Duro; the grant must be re-issued against an active package.                                         |

```json
{
  "errors": [
    {
      "message": "No valid pending grant was found for this account.",
      "path": ["organization", "create"],
      "extensions": {
        "code": "GRANT_NONE",
        "statusCode": 403,
        "service": "foundation"
      }
    }
  ]
}
```

{% hint style="warning" %}
Do not branch on `GRANT_ALREADY_CONSUMED` or `GRANT_REVOKED` to detect a spent or revoked grant — those two codes are raised **only** when the state changes mid-request, under concurrency. In the ordinary sequential case, a spent, revoked, or expired grant is indistinguishable from no grant at all and returns `GRANT_NONE`. Handle `GRANT_NONE` as the general "you cannot create an organization" case and treat the other two as rare race outcomes.
{% endhint %}

{% hint style="info" %}
[`Me.canCreateOrg`](/getting-started/current-user.md) is a UI hint that reflects eligibility at query time. Because a grant can be consumed or revoked in the interim, treat the create mutation and these error codes as the source of truth.
{% endhint %}

### Import Job Errors

These codes describe the import **job** as a whole, rather than an individual row. Per-row failures are reported on `rowResults` — see [Row Outcomes](/core-concepts/importing-components.md#row-outcomes) and [Sourcing Import Errors](#sourcing-import-errors).

| Code                                       | Status | Meaning                                                                                                                                                                                                                                    | How to resolve                                                                     |
| ------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
| `IMPORT_JOB_TERMINALIZED_DURING_PROMOTION` | 409    | The job was moved to a terminal state — typically because it was [cancelled](/core-concepts/importing-components.md) — while its components were being committed. The promotion is rolled back, so no component from the sheet is written. | Nothing was partially applied. Re-upload the sheet if the import was still wanted. |

{% hint style="info" %}
Because the promotion is rolled back rather than half-applied, a job that ends this way never leaves components behind. The converse also holds: once a job's components have committed, the job does not report `FAILED` — a later hiccup in the clean-up that follows the commit leaves the job `COMPLETED` with the detail recorded in `errorDetails`.
{% endhint %}

### Sourcing Import Errors

Columns mapped to a [`src:` target](/core-concepts/importing-components.md#sourcing-columns-src-targets) are applied after the component rows are written. When a sourcing value cannot be applied, the failure is reported against the row with one of these codes.

Note that an unrecognized name is generally **not** an error: unknown manufacturers, distributors, and [package types](/core-concepts/importing-components.md#package-type-resolution) are created on demand. The codes below cover the cases where a name resolves to something the import will not act on, or cannot be resolved at all.

{% hint style="info" %}
`SOURCING_IMPORT_PACKAGE_TYPE_UNKNOWN` has been retired. An unknown package type is now created rather than refused, so the code is no longer raised; handle `SOURCING_IMPORT_PACKAGE_TYPE_ARCHIVED` and `SOURCING_IMPORT_PACKAGE_TYPE_UNRESOLVABLE` instead.
{% endhint %}

| Code                                        | Target                         | Meaning                                                                                                                                                                                                                                                                      | How to resolve                                                                                                                   |
| ------------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `SOURCING_IMPORT_PACKAGE_TYPE_ARCHIVED`     | `src:packageType`              | The value matches an **archived** package type. Matching is case-insensitive and covers archived package types, so the name cannot be re-created and the row is failed rather than silently reviving the archived type. Status `409`.                                        | Restore the package type in Duro, or use a different name.                                                                       |
| `SOURCING_IMPORT_PACKAGE_TYPE_AMBIGUOUS`    | `src:packageType`              | The value matches more than one package type — two existing types differing only by case — so the import cannot choose one. Creating a third would not resolve which one the sheet meant.                                                                                    | Use the exact, unambiguous package name.                                                                                         |
| `SOURCING_IMPORT_PACKAGE_TYPE_UNRESOLVABLE` | `src:packageType`              | The package type did not exist and could not be created — typically two rows of the same import racing on the same new name. Status `409`.                                                                                                                                   | Re-upload the sheet. The package type usually exists by then and the retry resolves it.                                          |
| `SOURCING_IMPORT_UNREADABLE_VALUE`          | `src:packageType`              | The cell is longer than 100 characters, which is a mis-mapped column rather than a package name.                                                                                                                                                                             | Check the column mapping, and shorten the value.                                                                                 |
| `SOURCING_IMPORT_PART_IDENTITY_CHANGED`     | `src:manufacturer` / `src:mpn` | The manufacturer part the row matched changed identity, or stopped being available, between being matched and being written — typically because it was edited or removed concurrently. The row is failed rather than written to a part that is no longer the one it matched. | Re-upload the sheet. The row was not partially applied, and on a settled library the retry resolves the part again and succeeds. |

```json
{
  "rowNumber": 7,
  "outcome": "FAILED_PROMOTION",
  "errors": [
    {
      "field": "src:packageType",
      "code": "SOURCING_IMPORT_PACKAGE_TYPE_ARCHIVED",
      "message": "Package type \"SOIC8-W\" is archived"
    }
  ]
}
```

### Integration Access Errors

Writes made on behalf of an integration are checked against the [principal model](/core-concepts/components.md#integration-access-and-provenance): open attributes are writable by anyone, managed attributes only by principals linked to the component. These codes are returned by component writes (including `reconcile`), by `linkComponent` / `unlinkComponent`, and by `setAttributeAccess`.

| Code                                  | Meaning                                                                                                                                                                                | How to resolve                                                                                                                                                                              |
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ATTRIBUTE_LOCKED_BY_INTEGRATION`     | The attribute is managed, and the caller is not one of its linked managers. Also covers the **frozen** case — a managed attribute with no linked manager at all is writable by no one. | Link the acting principal to the component and make it a manager of the attribute, or write a different attribute. Read `effectiveAttributeAccess` first to know which fields are editable. |
| `ATTRIBUTE_REQUIRES_COMPONENT_LINK`   | The acting integration manages the attribute but is not linked to this component. Open attributes never raise this — only managed ones.                                                | Call `linkComponent` for that component, then retry.                                                                                                                                        |
| `INTEGRATION_NOT_ENABLED_FOR_LIBRARY` | The acting integration is not enabled for the library in the `x-library` header.                                                                                                       | Enable the integration for that library, or target a library where it is enabled.                                                                                                           |
| `COMPONENT_NOT_FOUND`                 | The referenced component does not exist in the current library context.                                                                                                                | Check the id and the `x-library` header.                                                                                                                                                    |
| `ATTRIBUTE_NOT_FOUND`                 | The referenced attribute does not exist for the component's category in this library.                                                                                                  | Re-read the category's attributes; ids are library-scoped.                                                                                                                                  |
| `INTEGRATION_CLAIM_NOT_AUTHORIZED`    | The caller may not claim the component for the requested integration.                                                                                                                  | Check the caller's `integrations.configure` permission and that the target integration is the one the request is acting as.                                                                 |
| `CLAIM_REQUIRES_ACTING_INTEGRATION`   | A claim or link was requested on a request that has no acting integration. Duro will not infer one.                                                                                    | Make the call with integration credentials so the request has an acting integration.                                                                                                        |
| `STORE_PRINCIPAL_ALWAYS_READS`        | An access policy tried to give the Duro principal no-read or no-access on an attribute. Duro must always be able to render its own data.                                               | Use receive-only instead, which makes the attribute read-only in the web app while keeping it visible.                                                                                      |

Two further codes guard [exclusive attribute management](/core-concepts/components.md#attribute-access) — the `EXCLUSIVE_WRITE` write designation, which makes one principal the sole manager of an attribute. Both are returned by `setAttributeAccess`.

| Code                            | Meaning                                                                                                                  | How to resolve                                                                                       |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| `EXCLUSIVE_BLOCKED_BY_MANAGERS` | `EXCLUSIVE_WRITE` was requested for a principal while other principals still hold a write designation on that attribute. | Clear the other principals' write designations first, then retry. Duro will not demote them for you. |
| `EXCLUSIVE_MANAGER_EXISTS`      | A write designation was requested for a principal on an attribute another principal already manages exclusively.         | Remove the existing exclusive designation first, or designate a different attribute.                 |

{% hint style="info" %}
Neither call partially applies: a rejected `setAttributeAccess` leaves every existing designation exactly as it was. Read `accessPolicy` before granting exclusivity to see which principals you need to clear.
{% endhint %}

```json
// Pushing a CAD-managed attribute from an integration that is not linked to the component
{
  "errors": [
    {
      "message": "This attribute is managed and requires a link to the component.",
      "path": ["component", "update"],
      "extensions": {
        "code": "ATTRIBUTE_REQUIRES_COMPONENT_LINK",
        "statusCode": 403,
        "service": "foundation"
      }
    }
  ]
}
```

### Entitlement Errors

Some features and limits are governed by your organization's **subscription plan**. When a request targets a feature your plan does not include, or would exceed a quota your plan allows, the API returns an entitlement error. Match on `extensions.code` — it is the stable, programmatic key.

| Code                        | Meaning                                                                                                                                                                                                                                                                                                  | How to resolve                                                                     |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `FEATURE_NOT_IN_PLAN`       | The operation requires a feature that your organization's plan does not include. For example, configuring [Enterprise SSO](/account-management/enterprise-sso.md) requires the `SSO_SAML` entitlement and [SCIM provisioning](/account-management/scim-provisioning.md) requires the `SCIM` entitlement. | Contact your Duro account team to add the feature to your plan.                    |
| `SEAT_LIMIT_REACHED`        | The operation would exceed the number of user seats your plan allows.                                                                                                                                                                                                                                    | Remove unused members, or contact your Duro account team to raise your seat count. |
| `ENTITLEMENT_LIMIT_REACHED` | The operation would exceed another quota your plan allows (storage or sandbox organizations, for example). The message names the specific limit, and `extensions.entitlement` carries its machine key.                                                                                                   | Reduce usage below the limit, or contact your Duro account team to raise it.       |

All three are `403`. The messages interpolate the specific feature or counts, so branch on `code` — and, for `ENTITLEMENT_LIMIT_REACHED`, on `extensions.entitlement`.

```json
// Configuring SAML on an organization whose plan lacks the SSO_SAML entitlement
{
  "errors": [
    {
      "message": "SSO / SAML is not included in your plan.",
      "path": ["organization", "configureSaml"],
      "extensions": {
        "code": "FEATURE_NOT_IN_PLAN",
        "statusCode": 403,
        "service": "foundation"
      }
    }
  ]
}
```

```json
// Exceeding a quota — here, the sandbox_orgs limit on the parent organization
{
  "errors": [
    {
      "message": "Sandbox organizations limit reached (3/3). Upgrade your plan to increase this limit.",
      "path": ["organization", "create"],
      "extensions": {
        "code": "ENTITLEMENT_LIMIT_REACHED",
        "entitlement": "sandbox_orgs",
        "statusCode": 403,
        "service": "foundation"
      }
    }
  ]
}
```

{% hint style="info" %}
Entitlement enforcement applies to organizations with a subscription. These errors surface the limit that was hit so you can act on it — key on `extensions.code` rather than the human-readable `message`, which may change.
{% endhint %}

### Sandbox Organization Errors

[Sandbox organizations](/getting-started/api-v2-migration.md#sandbox-organizations) are metered against the parent organization's `sandbox_orgs` entitlement. Pass `parentOrganizationId` to `organization.create` to request one. These codes can come back when a sandbox operation is refused.

| Code                        | Status | Operation                                 | Meaning                                                                                                                                                                                                                                                                                                            |
| --------------------------- | ------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `SANDBOX_PARENT_FORBIDDEN`  | 403    | Create with `parentOrganizationId`        | The chosen parent cannot host the sandbox. The parent must exist, be an active production (non-sandbox) organization that you are the **Site Admin** of, and have a subscription. Sandboxes cannot be nested, so a sandbox can never be a parent. Being an Org Admin rather than the Site Admin is not sufficient. |
| `SANDBOX_NAME_TAKEN`        | 409    | Create with `parentOrganizationId`        | A sandbox reuses its parent's company, so its name must be unique alongside the parent and every sibling sandbox. Unlike the other three, this one is user-fixable: prompt for a different name.                                                                                                                   |
| `SANDBOX_NOT_AVAILABLE`     | 409    | Create with `parentOrganizationId`        | Sandbox creation is not currently enabled. Refresh your creation options and retry — the request is refused rather than silently falling through to the grant path, so no grant is consumed.                                                                                                                       |
| `SANDBOX_ARCHIVE_FORBIDDEN` | 403    | `organization.archiveSandboxOrganization` | The target cannot be archived through this mutation. It only archives sandbox organizations you administer — production organizations, and sandboxes you do not administer, are rejected.                                                                                                                          |

{% hint style="warning" %}
Exceeding the parent's sandbox quota does **not** return `SANDBOX_PARENT_FORBIDDEN`. It returns [`ENTITLEMENT_LIMIT_REACHED`](#entitlement-errors) with `extensions.entitlement` set to `sandbox_orgs`. `SANDBOX_PARENT_FORBIDDEN` covers only eligibility of the parent and your role on it.
{% endhint %}

`SANDBOX_PARENT_FORBIDDEN` and `SANDBOX_ARCHIVE_FORBIDDEN` are deliberately generic: several distinct causes collapse into one message so the response never reveals whether a given organization exists or why exactly it was refused. Do not try to infer the specific cause from them.

```json
// Creating a sandbox under an ineligible parent
{
  "errors": [
    {
      "message": "You cannot create a sandbox organization under that parent.",
      "path": ["organization", "create"],
      "extensions": {
        "code": "SANDBOX_PARENT_FORBIDDEN",
        "statusCode": 403,
        "service": "foundation"
      }
    }
  ]
}
```

```json
// Archiving an organization that is not an archivable sandbox
{
  "errors": [
    {
      "message": "You cannot archive that sandbox organization.",
      "path": ["organization", "archiveSandboxOrganization"],
      "extensions": {
        "code": "SANDBOX_ARCHIVE_FORBIDDEN",
        "statusCode": 403,
        "service": "foundation"
      }
    }
  ]
}
```

{% hint style="info" %}
Before offering a "create sandbox" action, query `orgCreationOptions` to check whether the user can create a sandbox and which parent organizations still have headroom. See [Sandbox Organizations](/getting-started/api-v2-migration.md#sandbox-organizations).
{% endhint %}

### Best Practices

* Implement proper error handling
* Add retry logic for rate limits
* Log errors for debugging
* Handle network timeouts
* Provide user-friendly error messages

### Error Recovery

```typescript
async function queryWithRetry(query: string, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await executeQuery(query);
    } catch (error) {
      if (!isRetryableError(error) || attempt === maxRetries) {
        throw error;
      }
      await delay(exponentialBackoff(attempt));
    }
  }
}
```

### Next Steps

Join our [Developer Community](/community/developer-community.md) for support and discussions.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.durohub.com/advanced-topics/error-handling.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
