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

# SDK errors, retries & idempotency

> The JavaScript and Python SDK error-class trees, the BillingError family, the automatic retry policy, and how the Idempotency-Key header relates to run de-duplication.

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

Both SDKs are at v1.0.0 and parse every HTTP error into a single, typed exception tree, so you discriminate failures with `instanceof` (JavaScript) or `except` (Python) rather than by inspecting status codes by hand. This page is the exhaustive reference for those classes, the automatic retry loop each SDK runs, and the one idempotency behavior that does not work the way the SDK surface suggests.

The wire-level error contract — the three HTTP envelope shapes the backend emits and which surface produces each — lives on [Errors & status codes](/api-reference/errors). This page covers how the SDKs map that contract onto exception classes. Read both together: the SDKs are more unified than the backend, because each parses all envelope shapes into one class tree.

<Note>
  Every example authenticates with `Authorization: Bearer mx_live_…` plus the `X-Organization-ID` header, set once on the client. See [Authentication](/api-reference/authentication) for how those headers are resolved.
</Note>

## How an error reaches your code

Every resource method routes through the SDK's HTTP engine. On any response with status `>= 400`, the engine parses the body as JSON (falling back to a `{detail}` shape built from the response status text when the body is not JSON), then constructs the matching exception class and either retries it or raises it.

<Steps>
  <Step title="Parse the body">
    The engine reads the response body. If it is valid JSON it is kept verbatim as the error `body`; if not, it becomes `` `{detail: <status text>}` ``.
  </Step>

  <Step title="Classify by status (and by envelope, for billing)">
    The status code selects the class. For `402`, `403`, and `429` the SDK first looks for a structured denial envelope (the flat `` `{code, layer, key, current, limit, reason}` `` shape) and, in Python, routes to a `BillingError` subclass when it finds one.
  </Step>

  <Step title="Retry or raise">
    If the status is retryable and attempts remain, the engine waits and retries. Otherwise it raises the typed exception. See [Retry policy](#retry-policy).
  </Step>
</Steps>

<MediaEmbed id="MX-MEDIA-2040" type="image" caption={"Side-by-side diagram of the JavaScript and Python SDK error-class trees with the status-to-class mapping."} />

## Error class trees

### Base class

Every SDK error subclasses `ModulexError`. Catch it to handle any API failure uniformly.

<CodeGroup>
  ```python Python theme={null}
  from modulex import ModulexError

  try:
      await client.workflows.get("wf-does-not-exist")
  except ModulexError as e:
      print(e.status_code)   # int | None  (None for stream/timeout)
      print(e.message)       # human-readable summary
      print(e.body)          # parsed envelope (dict) or {"detail": text}
      print(e.response)      # the raw httpx.Response (or None)
  ```

  ```typescript JavaScript theme={null}
  import { ModulexError } from 'modulex-js';

  try {
    await client.workflows.get('wf-does-not-exist');
  } catch (e) {
    if (e instanceof ModulexError) {
      console.log(e.status);   // number | undefined
      console.log(e.message);  // human-readable summary
      console.log(e.body);     // parsed envelope or { detail: <statusText> }
      console.log(e.headers);  // raw Headers | undefined
      console.log(e.code, e.layer, e.reason); // structured fields, if present
    }
  }
  ```
</CodeGroup>

<ResponseField name="status / status_code" type="number | undefined">
  The HTTP status. `undefined`/`None` for transport errors (`StreamError`, `TimeoutError`), which never reach the server.
</ResponseField>

<ResponseField name="body" type="object">
  The raw parsed error body. For billing denials this is the flat `` `{code, layer, key, current, limit, reason}` `` envelope; for the common case it is `` `{detail: <string>}` ``; for validation failures it is `` `{detail: [{loc, msg, type}, …]}` ``.
</ResponseField>

<ResponseField name="code / reason / layer">
  Machine-stable fields lifted from a structured envelope when one is present. In JavaScript these live on the base `ModulexError`, so every subclass exposes `.code`, `.reason`, and `.layer`. In Python they live on the `BillingError` family (and `code`/`reason`/`layer`/`key`/`current`/`limit` on its members). All are absent (`undefined`/`None`) when the response carried no structured envelope.
</ResponseField>

### Status-mapped subclasses (both SDKs)

Each HTTP status maps to exactly one class. Discriminate by `instanceof` / `except`, never by class-name string.

| Status         | JavaScript class                     | Python class                                | Meaning in ModuleX                                                                                                                 |
| -------------- | ------------------------------------ | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| 400            | `BadRequestError`                    | `BadRequestError`                           | Missing header or required body field, bad input.                                                                                  |
| 401            | `AuthenticationError`                | `AuthenticationError`                       | No or invalid auth, expired token, invalid API key.                                                                                |
| 402            | `ModulexError` (base — no 402 class) | `PaymentRequiredError` (a `BillingError`)   | Payment required. See [billing divergence](#the-billingerror-family-python-only).                                                  |
| 403            | `PermissionError`                    | `PermissionError`                           | Inactive user, wrong org role, API-key scope mismatch. (Python shadows the builtin — see the [gotcha](#shadowed-builtins-python).) |
| 404            | `NotFoundError`                      | `NotFoundError`                             | Not found **or** not owned by your org — identical 404 by design, no existence leak.                                               |
| 409            | `ConflictError`                      | `ConflictError`                             | Conflict.                                                                                                                          |
| 422            | `ValidationError`                    | `ValidationError`                           | Request validation failed; `body.detail` is the FastAPI `` `[{loc, msg, type}]` `` array.                                          |
| 429            | `RateLimitError`                     | `RateLimitError` **or** base `BillingError` | Rate limited. See [the 429 split](#the-429-split-python).                                                                          |
| 500            | `InternalError`                      | `InternalError`                             | Unhandled server error.                                                                                                            |
| 502            | `ExternalServiceError`               | `ExternalServiceError`                      | Upstream service error (for example, a Stripe failure).                                                                            |
| 503            | `ServiceUnavailableError`            | `ServiceUnavailableError`                   | Service unavailable or fail-closed.                                                                                                |
| other `>= 400` | `ModulexError` (base)                | `ModulexError` (base)                       | Any unmapped status (for example `410`) falls through to the base class.                                                           |

<Warning>
  Neither SDK has a dedicated `410 Gone` class — both fall through to the base `ModulexError`. The removed agentic "LLM mode" on `POST /workflows/run` returns `410`; use `client.assistant.chat()` instead (see the [Assistant](/assistant/overview)).
</Warning>

### Transport-only classes (no status)

These never carry an HTTP status because they are raised before or outside a server response. Both subclass `ModulexError`, so a single `except ModulexError` / `instanceof ModulexError` still catches them.

| Class          | When it is raised                                                                                                                                      |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `StreamError`  | An SSE stream failed — for example, the response body was null, or an unexpected error occurred while iterating events.                                |
| `TimeoutError` | The request timed out or its `AbortSignal`/timeout fired. **Python shadows the builtin** `TimeoutError` (see the [gotcha](#shadowed-builtins-python)). |

<Note>
  On an SSE connect that returns `>= 400`, both SDKs raise the matching typed exception (for example `NotFoundError` on a `404`, or a `BillingError`/`RateLimitError` on a denial) **once**, before any event is yielded. SSE streams do **not** auto-reconnect. See [SSE run streaming](/realtime/sse-streaming) and [Streaming & HITL](/sdks/streaming-hitl).
</Note>

### `RateLimitError` extras (429, both SDKs)

A `RateLimitError` carries four extra fields parsed from the response headers. Each header is omitted by the backend when its value is null, so you may see only some of them.

| JavaScript field | Python field  | Header                  | Notes                                                                                                                                       |
| ---------------- | ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `retryAfter`     | `retry_after` | `Retry-After`           | Python tolerates both delay-seconds and an HTTP-date; JavaScript parses the numeric form. The backend sends integer seconds (default `60`). |
| `limit`          | `limit`       | `X-RateLimit-Limit`     | The applicable limit.                                                                                                                       |
| `remaining`      | `remaining`   | `X-RateLimit-Remaining` | Requests left in the window.                                                                                                                |
| `reset`          | `reset`       | `X-RateLimit-Reset`     | Unix epoch seconds when the window resets.                                                                                                  |

```typescript JavaScript theme={null}
import { RateLimitError } from 'modulex-js';

try {
  await client.workflows.run({ workflowId: 'wf-uuid', input: { messages: [] } });
} catch (e) {
  if (e instanceof RateLimitError) {
    console.log(`(${e.code}) ${e.reason}; retry after ${e.retryAfter}s`);
    console.log(`limit=${e.limit} remaining=${e.remaining} reset=${e.reset}`);
  }
}
```

## The `BillingError` family (Python only)

The Python SDK adds a `BillingError` tree that the JavaScript SDK does not have. When the backend returns a structured [`DenialEnvelope`](/api-reference/errors) on `402`/`403`/`429`, Python routes it to a subclass based on the envelope's `layer`, and exposes the envelope fields as attributes.

| Python class           | Base           | Status / layer                                                                            |
| ---------------------- | -------------- | ----------------------------------------------------------------------------------------- |
| `BillingError`         | `ModulexError` | Base of the family; raised directly for a `rate`-layer denial or an absent/unknown layer. |
| `PaymentRequiredError` | `BillingError` | `402` (the bare-402 mapping, when no envelope layer is present).                          |
| `QuotaExceededError`   | `BillingError` | `403`, `layer="quota"`.                                                                   |
| `CreditExhaustedError` | `BillingError` | `402`, `layer="credit"`.                                                                  |
| `WalletError`          | `BillingError` | `402`, `layer="wallet"`.                                                                  |

A `BillingError` exposes the full envelope: `code`, `layer`, `key`, `current`, `limit`, `reason`, and `retry_after` (populated only on the `429` layer).

<CodeGroup>
  ```python Python theme={null}
  from modulex import (
      Modulex, ModulexError, NotFoundError, RateLimitError,
      BillingError, CreditExhaustedError, ValidationError,
  )

  async with Modulex(api_key="mx_live_...", organization_id="org-uuid") as client:
      try:
          await client.executions.run(workflow_id="wf-uuid", input={"messages": []})
      except CreditExhaustedError as e:          # 402, layer="credit"
          print(f"Out of credits: {e.current}/{e.limit} (code={e.code})")
      except BillingError as e:                   # any quota/credit/wallet/rate denial
          print(f"Denied [{e.layer}/{e.code}]: {e.reason}")
      except RateLimitError as e:                 # 429 (header path)
          print(f"Backoff {e.retry_after}s (limit={e.limit}, remaining={e.remaining})")
      except NotFoundError:
          print("Not found (or not owned by your org — same 404)")
      except ValidationError as e:
          print(f"422: {e.message}")
      except ModulexError as e:                   # catch-all, incl. base for unmapped >= 400
          print(f"API error ({e.status_code}): {e.message}")
  ```

  ```typescript JavaScript theme={null}
  // JavaScript has NO billing subclasses. A 402, or a rate-layer denial envelope,
  // falls through to the base ModulexError — but .code, .layer, and .reason are
  // still populated from the envelope, so you branch on those fields instead.
  import { Modulex, RateLimitError, NotFoundError, ValidationError, ModulexError } from 'modulex-js';

  try {
    await client.workflows.run({ workflowId: 'wf-uuid', input: { messages: [] } });
  } catch (e) {
    if (e instanceof RateLimitError) {
      console.log(`429: retry after ${e.retryAfter}s`);
    } else if (e instanceof ModulexError && e.status === 402) {
      console.log(`Payment required [${e.layer}/${e.code}]: ${e.reason}`);
    } else if (e instanceof NotFoundError) {
      console.log('Not found (or not owned by your org)');
    } else if (e instanceof ValidationError) {
      console.log('422:', e.body);
    } else if (e instanceof ModulexError) {
      console.log(e.status, e.code, e.layer, e.reason);
    }
  }
  ```
</CodeGroup>

<Warning>
  **The two SDKs raise different classes for a `402`.** Python raises `PaymentRequiredError`/`CreditExhaustedError`/`WalletError` (depending on layer); JavaScript has no `402` class and falls through to the base `ModulexError`, with the envelope fields still on `.code`/`.layer`/`.reason`. Write your JavaScript billing handlers against the base class plus those fields.
</Warning>

### The 429 split (Python)

A `429` can arrive in two wire shapes, and Python maps them to two different classes:

* The **header-based rate limit** (string `detail` plus the `X-RateLimit-*` headers, from the per-key or per-user limiter) raises `RateLimitError`.
* A **`rate`-layer denial envelope** (the flat structured shape, from the run/managed-usage gate) raises the **base `BillingError`** — because `BillingError`'s layer routing only covers `quota`/`credit`/`wallet`.

So the same `429` status can raise either class depending on which limiter fired. To handle both, catch both:

```python Python theme={null}
from modulex import RateLimitError, BillingError

try:
    await client.executions.run(workflow_id="wf-uuid", input={"messages": []})
except RateLimitError as e:        # header-based limiter
    await asyncio.sleep(e.retry_after or 1.0)
except BillingError as e:          # rate-layer denial envelope (e.layer == "rate")
    print(f"Rate denied: {e.code} ({e.reason})")
```

JavaScript surfaces both `429` shapes as `RateLimitError` (it discriminates on status, not on envelope shape), so no equivalent split exists there.

## Retry policy

Both SDKs retry transient failures automatically. They agree on which statuses are retryable but **differ on which HTTP methods are retried**.

| Aspect                               | JavaScript                                                                              | Python                                                                               |
| ------------------------------------ | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| Retryable statuses                   | `429`, `500`, `502`, `503`                                                              | `429`, `500`, `502`, `503`                                                           |
| Never retried                        | `400`, `401`, `403`, `404`, `409`, `422` (raised immediately)                           | `400`, `401`, `403`, `404`, `409`, `422` (raised immediately)                        |
| Retried HTTP methods                 | **all methods**                                                                         | **GET and HEAD only** — mutations are never retried                                  |
| Default `maxRetries` / `max_retries` | `3`                                                                                     | `3`                                                                                  |
| Total attempts                       | `maxRetries + 1`                                                                        | `max_retries + 1`                                                                    |
| Backoff                              | exponential `` `500 * 2^attempt` `` ms + jitter `` `(0–500ms)` ``, capped at `30000` ms | exponential `` `0.5 * 2^attempt` `` s + jitter up to `0.5` s, capped at `30.0` s     |
| `Retry-After` honored                | yes — overrides backoff on `429`                                                        | yes — overrides backoff on `429`                                                     |
| Network/timeout errors               | retried while attempts remain                                                           | retried **only** for GET/HEAD while attempts remain; otherwise raises `TimeoutError` |
| SSE streams                          | not retried, no auto-reconnect                                                          | not retried, no auto-reconnect                                                       |

<Warning>
  **Python does not retry mutations.** A failed `POST`/`PUT`/`PATCH`/`DELETE` is raised on the first attempt, even for a retryable status. The JavaScript SDK retries all methods, including mutations — so a transient `429`/`5xx` on a mutating call is retried in JavaScript but not in Python. If you depend on automatic retry of a write, do it in JavaScript or implement your own bounded retry in Python.
</Warning>

Configure the retry budget per client:

<CodeGroup>
  ```python Python theme={null}
  from modulex import Modulex

  client = Modulex(
      api_key="mx_live_...",
      organization_id="org-uuid",
      max_retries=5,   # default 3; constructor-only (no env fallback)
      timeout=30.0,    # seconds; default 30.0
  )
  ```

  ```typescript JavaScript theme={null}
  import { Modulex } from 'modulex-js';

  const client = new Modulex({
    apiKey: 'mx_live_...',
    organizationId: 'org-uuid',
    maxRetries: 5,   // default 3
    timeout: 30_000, // milliseconds; default 30_000
  });
  ```

  ```bash cURL theme={null}
  # With raw HTTP you implement retries yourself. Retry only 429/500/502/503,
  # and honor the Retry-After header on a 429.
  curl -sS -X POST https://api.modulex.dev/workflows/run \
    -H "Authorization: Bearer mx_live_..." \
    -H "X-Organization-ID: org-uuid" \
    -H "Content-Type: application/json" \
    -d '{"workflow_id": "wf-uuid", "input": {"messages": []}, "stream": false}'
  ```
</CodeGroup>

<Note>
  For the rate limits themselves — which limiters exist and what the `429` responses contain — see [Rate limiting](/api-reference/rate-limiting). For the per-surface billing gate that produces the `402`/`403`/`429` denial envelopes, see [Usage gating & limits](/billing/usage-gating).
</Note>

## Idempotency

This is the one place where the SDK surface promises more than the backend delivers.

The Python `executions.run(...)` method accepts an `idempotency_key=` argument, and when you pass it the SDK attaches an `Idempotency-Key` request header. Its docstring suggests you can "safely retry a run without double-execution," but the header does **not** de-duplicate runs.

<Warning>
  **The `Idempotency-Key` header does not de-duplicate runs.** `POST /workflows/run` assigns its own `run_id` on every call, so a caller-supplied `Idempotency-Key` does not prevent a duplicate: retrying the same run with the same key starts a new, separately-billed run. Do not rely on it to prevent double-execution.
</Warning>

Two consequences for your code:

* **Do not treat run submission as idempotent.** If a `POST /workflows/run` call fails ambiguously (for example a network error after the request may have been received), retrying can execute the workflow twice. Because Python does not auto-retry mutations, this only happens when you retry yourself — so guard those retries with your own application-level dedup (for example, check the [run history](/concepts/workflows-and-runs) before resubmitting).
* **The JavaScript SDK does not send the header.** There is no `idempotencyKey` parameter in the JavaScript SDK, consistent with the header not being used for run de-duplication.

```python Python theme={null}
# `idempotency_key` is accepted and sent as a header, but the backend ignores it
# for run dedup — it mints its own run_id. Passing it does no harm; it just does
# not protect against double-execution.
result = await client.executions.run(
    workflow_id="wf-uuid",
    input={"messages": []},
    idempotency_key="my-key-123",  # sent, but a no-op server-side for run dedup
)
print(result.run_id)  # always a fresh, backend-minted run_id
```

For background on the distinct identities a `run_id` carries (per-turn, per-conversation `thread_id`, and the durable run row), see [Workflows & runs](/concepts/workflows-and-runs).

## Gotchas

### Shadowed builtins (Python)

The Python SDK defines `PermissionError` and `TimeoutError`, which **shadow the Python builtins** of the same name. When both the SDK names and the builtins are in scope, `except TimeoutError` catches the ModuleX one, not `asyncio.TimeoutError` or `builtins.TimeoutError`. Import them explicitly and be deliberate about which you catch:

```python Python theme={null}
from modulex import TimeoutError as ModulexTimeoutError, PermissionError as ModulexPermissionError

try:
    await client.workflows.run(workflow_id="wf-uuid", input={"messages": []})
except ModulexTimeoutError:
    print("ModuleX request timed out")
```

### Responses stay snake\_case

The SDKs convert your **request** keys to snake\_case on the way out, but they do **not** convert responses back. Error bodies and their fields (for example `has_more`, `next_cursor`, `retry_after` inside `body`) stay snake\_case as the backend sent them. The typed exception attributes (`retryAfter` in JavaScript, `retry_after` in Python) follow each language's convention, but `error.body` is the raw wire shape.

## Status, class, and retry at a glance

| Status          | JavaScript class          | Python class                                                    | Retried?                       |
| --------------- | ------------------------- | --------------------------------------------------------------- | ------------------------------ |
| 400             | `BadRequestError`         | `BadRequestError`                                               | no                             |
| 401             | `AuthenticationError`     | `AuthenticationError`                                           | no                             |
| 402             | base `ModulexError`       | `PaymentRequiredError` / `CreditExhaustedError` / `WalletError` | no                             |
| 403             | `PermissionError`         | `PermissionError` / `QuotaExceededError`                        | no                             |
| 404             | `NotFoundError`           | `NotFoundError`                                                 | no                             |
| 409             | `ConflictError`           | `ConflictError`                                                 | no                             |
| 410             | base `ModulexError`       | base `ModulexError`                                             | no                             |
| 422             | `ValidationError`         | `ValidationError`                                               | no                             |
| 429             | `RateLimitError`          | `RateLimitError` / base `BillingError`                          | **yes** (honors `Retry-After`) |
| 500             | `InternalError`           | `InternalError`                                                 | **yes**                        |
| 502             | `ExternalServiceError`    | `ExternalServiceError`                                          | **yes**                        |
| 503             | `ServiceUnavailableError` | `ServiceUnavailableError`                                       | **yes**                        |
| other `>= 400`  | base `ModulexError`       | base `ModulexError`                                             | no                             |
| (stream)        | `StreamError`             | `StreamError`                                                   | no                             |
| (timeout/abort) | `TimeoutError`            | `TimeoutError`                                                  | terminal                       |

<Card title="Next: the wire-level error contract" icon="webhook" href="/api-reference/errors">
  The three HTTP error-envelope shapes, which surface emits each, and the full status taxonomy the SDK classes map onto.
</Card>
