> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modulex.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors & status codes

> Every ModuleX API error: the four error-envelope shapes, the full HTTP status taxonomy, which surface emits the DenialEnvelope (402/403/429), and how the SDKs map each status to a typed error class.

export const MediaEmbed = ({id, type = 'screenshot', caption = '', ext, ratio = '16 / 9'}) => {
  const isVideo = type === 'video' || type === 'app_video';
  const resolvedExt = ext || (isVideo ? 'mp4' : type === 'screenshot' ? 'webp' : 'svg');
  const src = 'https://media.modulex.dev/' + id + '.' + resolvedExt;
  const [status, setStatus] = useState('loading');
  const [isDev, setIsDev] = useState(false);
  const [inView, setInView] = useState(false);
  const boxRef = useRef(null);
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const h = window.location.hostname;
    setIsDev(h === 'localhost' || h === '127.0.0.1' || h.endsWith('.mintlify.app'));
  }, []);
  useEffect(() => {
    if (inView) return;
    if (typeof IntersectionObserver === 'undefined') {
      setInView(true);
      return;
    }
    const el = boxRef.current;
    if (!el) return;
    const io = new IntersectionObserver(entries => {
      if (entries.some(e => e.isIntersecting)) {
        setInView(true);
        io.disconnect();
      }
    }, {
      rootMargin: '300px'
    });
    io.observe(el);
    return () => io.disconnect();
  }, [inView]);
  if (status === 'missing') {
    if (!isDev) return null;
    return <div style={{
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      gap: '0.4rem',
      padding: '1rem 1.25rem',
      margin: '1.25rem 0',
      width: '100%',
      aspectRatio: ratio,
      boxSizing: 'border-box',
      border: '1px dashed rgba(128,128,128,0.45)',
      borderRadius: '0.75rem',
      background: 'rgba(128,128,128,0.06)',
      color: 'currentColor',
      fontSize: '0.85rem',
      lineHeight: 1.45
    }}>
        <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: '0.5rem',
      opacity: 0.75
    }}>
          <span aria-hidden="true">🎬</span>
          <code style={{
      fontSize: '0.75rem'
    }}>{id}</code>
          <span style={{
      fontSize: '0.65rem',
      textTransform: 'uppercase',
      letterSpacing: '0.04em',
      padding: '0.1rem 0.4rem',
      borderRadius: '0.4rem',
      background: 'rgba(128,128,128,0.18)'
    }}>
            {type}
          </span>
        </div>
        <div style={{
      opacity: 0.9
    }}>{caption || 'Media not uploaded yet.'}</div>
        <div style={{
      fontSize: '0.7rem',
      opacity: 0.5
    }}>
          Upload to R2 as <code>{id}.{resolvedExt}</code> — preview only, hidden in production.
        </div>
      </div>;
  }
  const mediaStyle = {
    display: status === 'loaded' ? 'block' : 'none',
    width: '100%',
    height: 'auto',
    borderRadius: '0.75rem'
  };
  const media = isVideo ? <video src={inView ? src : undefined} autoPlay loop muted playsInline preload="metadata" onLoadedData={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} /> : <img src={inView ? src : undefined} alt={caption} onLoad={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} />;
  return <figure style={{
    margin: '1.25rem 0'
  }}>
      <div ref={boxRef} style={status === 'loaded' ? {
    width: '100%'
  } : {
    width: '100%',
    aspectRatio: ratio,
    borderRadius: '0.75rem',
    background: 'rgba(128,128,128,0.06)'
  }}>
        {media}
      </div>
      {status === 'loaded' && caption ? <figcaption style={{
    marginTop: '0.5rem',
    textAlign: 'center',
    fontSize: '0.85rem',
    opacity: 0.7
  }}>
          {caption}
        </figcaption> : null}
    </figure>;
};

ModuleX does not wrap every error in one schema. The API returns **four coexisting
error-envelope shapes**, and which one you get depends on the surface you call and the
failure that occurred. This page documents all four, the full HTTP status taxonomy, and
how the [JavaScript SDK](/sdks/javascript) and [Python SDK](/sdks/python) normalize each
status into a typed error class so you can branch on it in code.

The most important distinction: a **402 / 403 / 429** can mean two very different things.
On the **run, Composer, Assistant, and managed-knowledge** surfaces, it is a structured
[billing-gate](/billing/usage-gating) denial — the flat `DenialEnvelope` (shape D). On
plain CRUD and org-settings routes, the same status codes carry only the standard
`{"detail": …}` shape. The two are not interchangeable, and a client that handles billing
denials must branch on both.

<MediaEmbed id="MX-MEDIA-1220" type="image" caption={"A decision diagram that maps an HTTP error response to one of the four ModuleX envelope shapes."} />

## The four envelope shapes

There is no global error wrapper and no shared error-schema module. Each shape below is
emitted by a different code path. Read them top to bottom — A is by far the most common.

<CodeGroup>
  ```json Shape A — {"detail": "<string>"} theme={null}
  {
    "detail": "X-Organization-ID header is required"
  }
  ```

  ```json Shape B — {"detail": {…struct…}} theme={null}
  {
    "detail": {
      "code": "rate_limited",
      "layer": "rate",
      "key": "api",
      "current": 300,
      "limit": 300,
      "reason": "rate_limit_exceeded"
    }
  }
  ```

  ```json Shape C — 422 validation array theme={null}
  {
    "detail": [
      {
        "loc": ["query", "q"],
        "msg": "String should have at least 2 characters",
        "type": "string_too_short"
      }
    ]
  }
  ```

  ```json Shape D — flat DenialEnvelope theme={null}
  {
    "code": "credit_plan_exhausted",
    "layer": "credit",
    "key": "sync_exec",
    "current": null,
    "limit": 5000.0,
    "reason": "credit_plan_exhausted"
  }
  ```
</CodeGroup>

### Shape A — `{"detail": "<string>"}`

The standard FastAPI / Starlette `HTTPException` envelope, used by essentially every
router. The `detail` value is a plain human-readable string. Missing headers, missing
body fields, not-found resources, role and scope rejections, and most server errors all
arrive in this shape.

This is also one of the three live **429** paths: the per-key and per-user rate limiter
returns a string `detail` (`"API key rate limit exceeded"` or
`"User rate limit exceeded (across all API keys)"`) alongside the rate-limit headers.
See [Rate limiting](/api-reference/rate-limiting).

### Shape B — `{"detail": {…struct…}}`

A few sites pass a **dictionary** as `detail`, which the framework serializes verbatim
under the `detail` key. There are two live producers:

<Expandable title="The two live Shape B producers">
  * **Org-level rate-limit deny (429).** The org `api`-class limiter returns
    `{"detail": {"code": "rate_limited", "layer": "rate", "key": "api", "current": …,
    "limit": …, "reason": "rate_limit_exceeded"}}`. Note the `code` is `"rate_limited"`
    here — not `"rate_limit_exceeded"` as in shape D — and the whole struct is wrapped in
    `detail`.
  * **Wallet paid-gate deny (402).** The `/subscriptions/wallet/extra-usage` and
    `/subscriptions/wallet/topup` routes return
    `{"detail": {"reason": "paid_subscription_required"}}`. This is a plain `HTTPException`,
    **not** a `DenialEnvelope`.
</Expandable>

<Warning>
  **A dict `detail` is not a `DenialEnvelope`.** Shape B nests its fields inside `detail`,
  and its `code` for the org limiter is `"rate_limited"`. The flat `DenialEnvelope` (shape
  D) has **no** `detail` wrapper and uses `code: "rate_limit_exceeded"`. Same status code,
  different wire shape — see [the multiple 402 and 429 shapes](#status-codes-with-more-than-one-live-shape).
</Warning>

### Shape C — 422 validation array

There is no custom 422 handler, so request-validation failures use the framework default:
`{"detail": [ {loc, msg, type}, … ]}`. Each array item identifies the field, a message,
and an error type. This is triggered by query/body/path constraints such as a minimum
string length.

<Note>
  The exact field set of the 422 array is the framework's default and was not confirmed by
  a backend override. Treat `loc`, `msg`, and `type` as the fields you can rely on, and read
  the array as a list of per-field problems rather than a fixed schema.
</Note>

### Shape D — flat `DenialEnvelope`

The structured billing / usage / rate / quota denial. It is **flat** — its fields sit at
the top level of the body, **not** under `detail`. This is the envelope the
[usage gate](/billing/usage-gating) raises, and it is **live** on the run, Composer,
Assistant, and managed-knowledge surfaces.

<ResponseField name="code" type="string" required>
  The machine-stable denial code. Branch on this in your client. One of
  `rate_limit_exceeded`, `quota_exceeded`, `credit_plan_exhausted`,
  `wallet_overage_disabled`, `wallet_insufficient`, or `upgrade_payment_failed`.
</ResponseField>

<ResponseField name="layer" type="string" required>
  Which gate layer denied the request: `rate`, `quota`, `credit`, or `wallet`. The layer
  determines the HTTP status — `rate` → 429, `quota` → 403, `credit` → 402, `wallet` → 402.
</ResponseField>

<ResponseField name="key" type="string | null">
  The limit or counter key that was hit, such as `sync_exec` or `max_knowledge_bases`.
  `null` when not applicable.
</ResponseField>

<ResponseField name="current" type="number | null">
  The observed usage or count at the moment of denial. `null` when the gate does not
  report a running total for this denial.
</ResponseField>

<ResponseField name="limit" type="number | null">
  The limit that was reached. `null` when no numeric limit applies (for example, a
  suspended subscription).
</ResponseField>

<ResponseField name="reason" type="string | null">
  A short reason token, for example `overage_disabled`, `insufficient_balance`, or
  `credit_plan_exhausted`. Frequently mirrors `code`, but may differ.
</ResponseField>

#### `DenialEnvelope` codes

Each gate layer maps to a fixed `code`, HTTP status, and meaning:

| `code`                    | `layer`  | HTTP | What it means                                                                                                                             |
| ------------------------- | -------- | :--: | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `rate_limit_exceeded`     | `rate`   |  429 | Per-period run/usage rate limit reached on a gated surface. Carries `Retry-After` plus `X-RateLimit-*` headers (each omitted if not set). |
| `quota_exceeded`          | `quota`  |  403 | A countable entitlement (for example, the maximum number of knowledge bases) is at its limit.                                             |
| `credit_plan_exhausted`   | `credit` |  402 | The plan's credit allowance is used up and no wallet overage is available (or the subscription is suspended).                             |
| `wallet_overage_disabled` | `wallet` |  402 | The allowance is exhausted and overage spending is turned off for the organization.                                                       |
| `wallet_insufficient`     | `wallet` |  402 | Overage is on, but the prepaid wallet does not have enough balance.                                                                       |
| `upgrade_payment_failed`  | `credit` |  402 | A plan-upgrade proration charge failed.                                                                                                   |

For where these come from and how credits are metered, see
[Usage gating & limits](/billing/usage-gating) and [Credits & metering](/billing/credits).

## Which surface emits which shape

This is the single most important fact about ModuleX errors.

<Note>
  **Shape D (`DenialEnvelope`) is live on run and managed-usage surfaces, and absent
  everywhere else.** A 402 / 403 / 429 from a CRUD or org-settings route is **never** a
  `DenialEnvelope` — it carries the `{"detail": …}` shapes (A or B). A 402 / 403 / 429 from
  a gated surface **can** be a `DenialEnvelope`.
</Note>

<Tabs>
  <Tab title="Gated surfaces (Shape D live)">
    The usage gate runs on these surfaces, so they can return a flat `DenialEnvelope`
    (shape D) for 402 / 403 / 429 — in addition to the standard `{"detail": …}` shapes for
    other failures:

    * `POST /workflows/run` — workflow execution, with the workflow id in the JSON body
      (`workflow_id`). See [Run via API](/workflow-builder/execution/api-endpoint).
    * The [AI Composer](/concepts/ai-composer) chat and resume endpoints.
    * The [Assistant](/concepts/assistant) chat and resume endpoints.
    * Managed-knowledge retrieval and ingest on
      [managed knowledge](/platform/knowledge/managed) bases.

    On these surfaces, branch on the flat top-level `code` / `layer` fields.
  </Tab>

  <Tab title="CRUD & org-settings (Shape D absent)">
    Plain CRUD and org-settings routes never call the usage gate. They only return the
    `{"detail": …}` shapes (A, or B for the wallet/org-limiter cases). Examples:

    * `GET` / `POST` / `PATCH` / `DELETE /workflows` and `/workflows/{workflow_id}`.
    * `GET` / `POST /knowledge-bases`, `/credentials`, `/api-keys`, `/schedules`.
    * `GET /organizations`, `GET /integrations`.

    A 402 / 403 / 429 here is a string `detail` (or, for the wallet routes and the org
    rate limiter, a dict `detail`) — not a `DenialEnvelope`.
  </Tab>
</Tabs>

## HTTP status taxonomy

Every status ModuleX returns, what it means in this product, and the envelope shape(s) you
should expect.

|  Status | Meaning in ModuleX                                                                                                                                                                      | Envelope shape(s)                      |
| :-----: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| **200** | Success. No global success wrapper — each route returns its own `response_model` or dict, in snake\_case.                                                                               | n/a                                    |
| **201** | Resource created (for example `POST /workflows`).                                                                                                                                       | n/a                                    |
| **400** | Bad request: missing `X-Organization-ID`, a missing required body field, an out-of-range value, or "no active deployment".                                                              | A                                      |
| **401** | No or invalid authentication: missing token, invalid Clerk JWT, or invalid API key. Carries `WWW-Authenticate: Bearer`. See [Authentication](/api-reference/authentication).            | A                                      |
| **402** | Payment required. **Wallet routes:** shape B `{"detail": {"reason": "paid_subscription_required"}}`. **Gated surfaces:** shape D `credit` / `wallet` denial.                            | B (wallet routes) · D (gated surfaces) |
| **403** | Forbidden: inactive user, wrong org role (`owner` / `admin` required — the `member` role is retired), or an API-key org-scope mismatch. **Gated surfaces:** shape D `quota` denial.     | A (CRUD / org) · D (gated `quota`)     |
| **404** | Not found — and not-owned-by-your-org is **identical** to not-found by design, so existence never leaks across organizations.                                                           | A                                      |
| **409** | Conflict. Both SDKs expose a `ConflictError`, but no backend producer is pinned. Handle it defensively.                                                                                 | A (assumed)                            |
| **410** | Gone: the removed "LLM mode" on `POST /workflows/run`, and stale or already-consumed [HITL](/realtime/hitl) resume requests.                                                            | A                                      |
| **422** | Request validation failure (the framework default array). Note: a malformed `workflow_schema` surfaces as **500**, not 422, because run/workflow bodies are typed as open dictionaries. | C (normal) · A (the 500 case)          |
| **429** | Rate limited. Three live shapes — see below.                                                                                                                                            | A · B · D (gated surfaces)             |
| **500** | Unhandled exception → catch-all `{"detail": "An unexpected internal server error occurred."}`; or a route's own wrapped 500 with a descriptive `detail`.                                | A                                      |
| **502** | External-service error, for example `"Stripe error: …"` from checkout, portal, or top-up.                                                                                               | A                                      |
| **503** | Service unavailable or fail-closed: an admin/health key not configured, or a transient Stripe-webhook retry.                                                                            | A                                      |

<Warning>
  **The catch-all 500 hides the original exception.** Any unhandled exception is returned as
  a fixed-string 500. Some routers, however, catch their own exceptions and re-raise a 500
  `HTTPException` with a descriptive `detail` before the catch-all sees it — so a 500 may or
  may not include a useful message. Do not parse 500 `detail` strings programmatically.
</Warning>

### Status codes with more than one live shape

Two status codes carry more than one wire shape at the same time. A client that handles
them must branch on **all** of the shapes below.

<Tabs>
  <Tab title="402 — two live shapes">
    A 402 can be either of these, depending on the route:

    ```json Wallet paid-gate (Shape B) theme={null}
    { "detail": { "reason": "paid_subscription_required" } }
    ```

    ```json Gated-surface credit/wallet denial (Shape D) theme={null}
    {
      "code": "credit_plan_exhausted",
      "layer": "credit",
      "key": "sync_exec",
      "current": null,
      "limit": 5000.0,
      "reason": "credit_plan_exhausted"
    }
    ```

    Shape B comes only from the two wallet routes; shape D comes from the usage gate on
    run / Composer / Assistant / managed-knowledge.
  </Tab>

  <Tab title="429 — three live shapes">
    A 429 can be any of these three. They disagree on `code` and on wrapping:

    ```json Per-key / per-user limiter (Shape A) theme={null}
    { "detail": "API key rate limit exceeded" }
    ```

    ```json Org api-class limiter (Shape B) theme={null}
    {
      "detail": {
        "code": "rate_limited",
        "layer": "rate",
        "key": "api",
        "current": 300,
        "limit": 300,
        "reason": "rate_limit_exceeded"
      }
    }
    ```

    ```json Gated-surface rate denial (Shape D) theme={null}
    {
      "code": "rate_limit_exceeded",
      "layer": "rate",
      "key": "sync_exec",
      "current": null,
      "limit": null,
      "reason": "rate_limit_exceeded"
    }
    ```

    All three may carry `Retry-After` and `X-RateLimit-*` headers. Note the `code`
    difference: `"rate_limited"` for the org limiter versus `"rate_limit_exceeded"` for the
    gate. See [Rate limiting](/api-reference/rate-limiting) for the header contract.
  </Tab>
</Tabs>

## Rate-limit headers

The 429 responses may carry these headers. Each is **omitted** when its value is not set,
so you may see only some of them on a given response.

<ResponseField name="Retry-After" type="integer (seconds)">
  How long to wait before retrying. Defaults to `60` on gated-surface rate denials. Both
  SDKs honor this for 429 backoff; the Python SDK also tolerates an HTTP-date form.
</ResponseField>

<ResponseField name="X-RateLimit-Limit" type="integer">
  The ceiling for the current window.
</ResponseField>

<ResponseField name="X-RateLimit-Remaining" type="integer">
  Remaining requests in the current window.
</ResponseField>

<ResponseField name="X-RateLimit-Reset" type="integer (Unix epoch seconds)">
  When the current window resets.
</ResponseField>

## How the SDKs map errors

Both SDKs are at v1.0.0 and parse every envelope shape into one class tree, so you catch a
typed error instead of inspecting raw JSON. Every error exposes `status`, the response
`body`, and the response `headers`; the structured `code`, `layer`, and `reason` fields are
surfaced where the envelope provides them.

The two SDKs differ in one important way: **Python adds a `BillingError` family** (with
`PaymentRequiredError`, `QuotaExceededError`, `CreditExhaustedError`, and `WalletError`)
that the JavaScript SDK does not have. In JavaScript, a 402 and a `rate`-layer envelope
fall through to the base `ModulexError` — with `code` / `layer` / `reason` still populated.

### Status → SDK class

|      Status     | JavaScript class                     | Python class                                                                        |
| :-------------: | ------------------------------------ | ----------------------------------------------------------------------------------- |
|       400       | `BadRequestError`                    | `BadRequestError`                                                                   |
|       401       | `AuthenticationError`                | `AuthenticationError`                                                               |
|       402       | `ModulexError` (base — no 402 class) | `PaymentRequiredError` / `CreditExhaustedError` / `WalletError`                     |
|       403       | `PermissionError`                    | `PermissionError` / `QuotaExceededError` (gated `quota`)                            |
|       404       | `NotFoundError`                      | `NotFoundError`                                                                     |
|       409       | `ConflictError`                      | `ConflictError`                                                                     |
|       410       | `ModulexError` (base — no 410 class) | `ModulexError` (base — no 410 class)                                                |
|       422       | `ValidationError`                    | `ValidationError`                                                                   |
|       429       | `RateLimitError`                     | `RateLimitError` (header path) **or** base `BillingError` (envelope `layer="rate"`) |
|       500       | `InternalError`                      | `InternalError`                                                                     |
|       502       | `ExternalServiceError`               | `ExternalServiceError`                                                              |
|       503       | `ServiceUnavailableError`            | `ServiceUnavailableError`                                                           |
|   other ≥ 400   | base `ModulexError`                  | base `ModulexError`                                                                 |
|   stream error  | `StreamError`                        | `StreamError`                                                                       |
| timeout / abort | `TimeoutError`                       | `TimeoutError`                                                                      |

<Warning>
  **In Python, one 429 status can raise two different classes.** A header-only 429 raises
  `RateLimitError`; a 429 whose body is a `layer="rate"` envelope raises base `BillingError`.
  JavaScript surfaces both as `RateLimitError` because it discriminates on status, not on
  envelope shape. In Python, catch **both** `RateLimitError` and `BillingError` to cover
  every 429. The Python SDK shadows the builtin `PermissionError` and `TimeoutError`.
</Warning>

### Catch and discriminate

The example below makes an authenticated request and handles a billing denial, a rate
limit, and validation errors. Authentication uses `Authorization: Bearer mx_live_…` plus
`X-Organization-ID` — see [Authentication](/api-reference/authentication).

<CodeGroup>
  ```bash cURL theme={null}
  # A 402 DenialEnvelope from the run surface looks like this on the wire.
  # Branch on the top-level `layer` / `code` (no `detail` wrapper).
  curl -i -X POST https://api.modulex.dev/workflows/run \
    -H "Authorization: Bearer mx_live_8Kd2pQ7nR4xWvL0eYbT1" \
    -H "X-Organization-ID: org_9a2f7c41" \
    -H "Content-Type: application/json" \
    -d '{"workflow_id": "wf_3f8a21c0", "input": {"topic": "quarterly report"}}'

  # HTTP/1.1 402 Payment Required
  # {"code":"credit_plan_exhausted","layer":"credit","key":"sync_exec",
  #  "current":null,"limit":5000.0,"reason":"credit_plan_exhausted"}
  ```

  ```python Python theme={null}
  from modulex import (
      Modulex,
      BillingError,
      CreditExhaustedError,
      WalletError,
      QuotaExceededError,
      RateLimitError,
      ValidationError,
      ModulexError,
  )

  async with Modulex(
      api_key="mx_live_8Kd2pQ7nR4xWvL0eYbT1",
      organization_id="org_9a2f7c41",
  ) as client:
      try:
          run = await client.executions.run(
              workflow_id="wf_3f8a21c0",
              input={"topic": "quarterly report"},
          )
      except CreditExhaustedError as err:
          # 402, layer="credit": plan allowance is used up.
          print(err.code, err.layer, err.limit)
      except WalletError as err:
          # 402, layer="wallet": overage off or balance insufficient.
          print(err.code, err.reason)
      except QuotaExceededError as err:
          # 403, layer="quota": a countable entitlement is at its limit.
          print(err.code, err.key, err.current, err.limit)
      except (RateLimitError, BillingError) as err:
          # 429 can raise either class — catch both. Back off on Retry-After.
          retry_after = getattr(err, "retry_after", None)
          print("rate limited; retry after", retry_after)
      except ValidationError as err:
          # 422: err.body["detail"] is the loc/msg/type array.
          print(err.body)
      except ModulexError as err:
          # Base catch-all for 402 falling through, 410, 409, 5xx, etc.
          print(err.status_code, err.code, err.reason)
  ```

  ```javascript JavaScript theme={null}
  import {
    Modulex,
    RateLimitError,
    ValidationError,
    PermissionError,
    ModulexError,
  } from 'modulex-js';

  const client = new Modulex({
    apiKey: 'mx_live_8Kd2pQ7nR4xWvL0eYbT1',
    organizationId: 'org_9a2f7c41',
  });

  try {
    const run = await client.executions.run({
      workflowId: 'wf_3f8a21c0',
      input: { topic: 'quarterly report' },
    });
  } catch (err) {
    if (err instanceof RateLimitError) {
      // 429 — back off using the parsed Retry-After header.
      console.log('rate limited; retry after', err.retryAfter);
    } else if (err instanceof PermissionError) {
      // 403 — role/scope rejection (Shape A).
      console.log(err.message);
    } else if (err instanceof ValidationError) {
      // 422 — err.body.detail is the loc/msg/type array.
      console.log(err.body);
    } else if (err instanceof ModulexError) {
      // 402 and 410 fall through to the base class here.
      // code / layer / reason are still populated for DenialEnvelope bodies.
      console.log(err.status, err.code, err.layer, err.reason);
    } else {
      throw err;
    }
  }
  ```
</CodeGroup>

<Note>
  **JavaScript has no 402 or billing class.** Catch the base `ModulexError` for 402 and read
  `err.code` / `err.layer` / `err.reason` — they are populated from the `DenialEnvelope`. The
  same applies to 410. See [Errors & retries](/sdks/errors-retries) for the full SDK error
  class list and the retry policy.
</Note>

### Retry classification

Both SDKs treat the same statuses as retryable and honor `Retry-After` for 429 backoff.

| Statuses                                 | Treatment                                               |
| ---------------------------------------- | ------------------------------------------------------- |
| `429`, `500`, `502`, `503`               | **Retryable** (with backoff; 429 honors `Retry-After`). |
| `400`, `401`, `403`, `404`, `409`, `422` | **Not retryable** — fix the request.                    |

The two SDKs differ on scope: the **Python SDK retries `GET` and `HEAD` only**, while the
**JavaScript SDK retries all methods**. SSE connection errors are thrown once, with no
automatic reconnect, in both SDKs. For the retry-budget details and idempotency behavior,
see [Errors & retries](/sdks/errors-retries).

## Related pages

<CardGroup cols={2}>
  <Card title="Rate limiting" icon="gauge-high" href="/api-reference/rate-limiting">
    The per-key, per-user, and per-org rate limits behind the 429 responses.
  </Card>

  <Card title="Usage gating & limits" icon="shield-halved" href="/billing/usage-gating">
    The billing admission gate that raises the 402 / 403 / 429 `DenialEnvelope`.
  </Card>

  <Card title="Authentication" icon="key" href="/api-reference/authentication">
    The auth headers behind 401 and 403 — `Authorization: Bearer` plus `X-Organization-ID`.
  </Card>

  <Card title="SDK errors & retries" icon="rotate" href="/sdks/errors-retries">
    The full error class tree, retry policy, and idempotency behavior in both SDKs.
  </Card>
</CardGroup>
