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

# Error handling

> Handle HTTP, connection, timeout, and job errors from ndi-sdk

Every HTTP, connection, and job failure raised by `ndi-sdk` inherits from
`NdiError`. Catch a specific subclass when your application can recover from
that failure, or catch `NdiError` at the outer SDK boundary.

Client configuration mistakes use Python's `ValueError`. For example, creating
a client without an API key or passing an empty `idempotency_key` fails before
an HTTP request is sent.

## Exception hierarchy

```text theme={"dark"}
NdiError
├── NdiConnectionError
│   └── NdiTimeoutError
├── NdiStatusError
│   ├── AuthenticationError       # 401
│   ├── QuotaExceededError        # 402
│   ├── PermissionDeniedError     # 403
│   ├── NotFoundError             # 404
│   ├── SyncWaitTimeoutError      # 408, legacy API only
│   ├── ConflictError             # 409
│   ├── ResultExpiredError        # 410
│   ├── RateLimitError            # 429
│   ├── NotImplementedByServerError # 501
│   ├── ServerError               # other 5xx
│   └── InvalidRequestError       # other non-2xx responses
├── JobFailedError
└── JobTimeoutError
```

## Handle common failures

```python theme={"dark"}
from ndi_sdk import (
    AuthenticationError,
    JobFailedError,
    JobTimeoutError,
    NdiClient,
    NdiConnectionError,
    NdiStatusError,
)

with NdiClient() as client:
    try:
        upload = client.documents.create_upload("report.pdf")
        queued = client.documents.parse(upload)
        job = client.jobs.wait(queued.job_id, timeout=600)
    except AuthenticationError as exc:
        print("Check NDI_API_KEY", exc.request_id)
    except JobFailedError as exc:
        print(exc.job.error)
    except JobTimeoutError as exc:
        print(f"Job continues running: {exc.job.job_id}")
    except NdiConnectionError as exc:
        print(f"Could not reach NDI: {exc}")
    except NdiStatusError as exc:
        print(exc.status_code, exc.code, exc.message, exc.request_id)
```

## HTTP status errors

`NdiStatusError` means the server returned a non-success HTTP response. It
exposes:

| Attribute        | Meaning                                                       |
| ---------------- | ------------------------------------------------------------- |
| `status_code`    | HTTP status                                                   |
| `code`           | Machine-readable `ErrorCode`, or `None` for a legacy response |
| `message`        | Human-readable server message                                 |
| `body`           | Parsed `/v1` error body when available                        |
| `retryable`      | Whether replaying the identical request could succeed         |
| `request_id`     | Correlation ID for logs and support                           |
| `method` / `url` | Failed request                                                |

Handle error codes when behavior differs within one HTTP status:

```python theme={"dark"}
from ndi_sdk import ErrorCode, RateLimitError

try:
    queued = client.documents.parse(upload)
except RateLimitError as exc:
    if exc.code == ErrorCode.CONCURRENCY_LIMIT_REACHED:
        print("Too many jobs are already in flight")
    elif exc.code == ErrorCode.RATE_LIMITED:
        print("Request rate limit reached")
    raise
```

`ErrorCode` is open-ended. Unknown codes remain usable values instead of
causing response validation to fail.

`unsupported_file_type` is an `InvalidRequestError` with status `422`. It is
returned synchronously by an upload boundary or document-operation start, so
there is no failed job to poll. A rejected workspace upload also creates no
file. Check `exc.code == ErrorCode.UNSUPPORTED_FILE_TYPE`; do not retry the
same request or maintain a separate client-side format allowlist.

## Job errors are different

An HTTP request can succeed while the asynchronous job later fails.
`JobFailedError` therefore contains the terminal `job`, including
`job.error.code`, `job.error.message`, and any job-specific detail.

```python theme={"dark"}
from ndi_sdk import JobFailedError

try:
    job = client.jobs.wait(job_id)
except JobFailedError as exc:
    error = exc.job.error
    if error is not None:
        print(error.code, error.message)
```

`JobTimeoutError` only means the local `jobs.wait` budget expired. The job
continues server-side and can be read again with `client.jobs.get(job_id)`.

## Connection and HTTP timeouts

`NdiConnectionError` means no response was produced because of DNS, TLS,
connection, or transport failure. `NdiTimeoutError` is its timeout-specific
subclass.

The client timeout defaults to 60 seconds per HTTP request:

```python theme={"dark"}
from ndi_sdk import NdiClient, NdiTimeoutError

try:
    with NdiClient(timeout=120) as client:
        job = client.jobs.get(job_id)
except NdiTimeoutError:
    print("The HTTP request timed out; the remote job may still be running")
```

This HTTP timeout is separate from `client.jobs.wait(..., timeout=600)`, which
sets the total polling budget.

## Automatic retries

The SDK retries safe requests after connection failures, HTTP `429`, and
`5xx` responses. The default `RetryPolicy` makes at most three attempts with
exponential backoff and full jitter. The defaults are `initial_backoff=0.5`
seconds and `max_backoff=8.0` seconds. A valid `Retry-After` response header
takes precedence.

```python theme={"dark"}
from ndi_sdk import NdiClient, RetryPolicy

job_id = "550e8400-e29b-41d4-a716-446655440003"
policy = RetryPolicy(max_attempts=5)

with NdiClient(retry_policy=policy) as client:
    job = client.jobs.get(job_id)
```

The SDK only retries a request when replay is safe. Read methods are
idempotent, and job-creating methods automatically mint an
`Idempotency-Key`. State conflicts (`409`) and invalid requests are not
retried. Streaming `jobs.events` connections are not retried automatically;
reconnect with `last_event_id` when needed.

## Best practices

<CardGroup cols={2}>
  <Card title="Catch specific exceptions" icon="code">
    Recover from known conditions such as rate limits or missing resources,
    then let unexpected SDK errors reach your application boundary.
  </Card>

  <Card title="Log the request ID" icon="file-text">
    Include `request_id`, `status_code`, and `code` in logs without recording
    API keys or document contents.
  </Card>

  <Card title="Distinguish HTTP and job failure" icon="split">
    A successful submission can still produce a failed job. Handle both
    `NdiStatusError` and `JobFailedError`.
  </Card>

  <Card title="Preserve idempotency" icon="repeat">
    Let the SDK mint keys, or reuse your explicit key when replaying the same
    job-creating request.
  </Card>
</CardGroup>

## Next steps

* [Job management](/sdks/python/job-management)
* [Async client](/sdks/python/async)
* [Errors and limits](/concepts/errors)
