---
title: Error handling
description: The exception hierarchy, when each error fires, and how to retry safely.
section: Reference
order: 0
---
Every SDK error subclasses **`PictographError`**. Catch the specific
subclass to handle a known failure mode; catch the base class to log
and rethrow.

## Hierarchy

```
PictographError
├── ConfigurationError    — missing API key, invalid base URL
├── AuthError             — 401 (bad / missing / revoked key)
├── ForbiddenError        — 403 (role lacks permission)
├── NotFoundError         — 404 (resource missing)
├── ConflictError         — 409 (duplicate name, optimistic-lock fail)
├── ValidationError       — 422 (payload shape rejected)
├── PaymentRequiredError  — 402 (out of credits)
├── RateLimitError        — 429 (per-key rate cap hit)
├── ServerError           — 5xx (transient backend failure)
├── NetworkError          — connection / DNS / TLS failure
├── RequestTimeoutError   — request exceeded the SDK's timeout budget
├── PollTimeoutError      — long-running job (training, batch SAM3) didn't finish
└── ApiError              — catch-all for unmatched status codes
```

Import from the top-level package:

```python
from pictograph.exceptions import (
    PictographError, AuthError, ForbiddenError, NotFoundError,
    ConflictError, ValidationError, PaymentRequiredError,
    RateLimitError, ServerError, NetworkError, RequestTimeoutError,
    PollTimeoutError, ApiError,
)
```

## When each fires

| Exception | Common cause | What to do |
|---|---|---|
| `ConfigurationError` | `PICTOGRAPH_API_KEY` not set, no `api_key=` arg | Set the env var or pass `api_key` |
| `AuthError` (401) | Key revoked / typo | Re-issue the key |
| `ForbiddenError` (403) | `viewer` key calling a write op | Use a `member`+ key |
| `NotFoundError` (404) | Dataset name typo (case-sensitive!) | Verify with `datasets list` |
| `ConflictError` (409) | Same image filename in same folder | Pass `skip_existing=True` to the upload workflow, or use a new name |
| `ValidationError` (422) | `class` instead of `name`, flat polygon array | Fix the payload (see [Annotation format](/docs/annotation-format.md)) |
| `PaymentRequiredError` (402) | Out of credits mid-operation | Show `e.upgrade_url` to the user |
| `RateLimitError` (429) | Per-key burst limit | SDK auto-retries when `Retry-After < 120s`; otherwise raise |
| `ServerError` (5xx) | Backend incident | SDK retries with exponential backoff; persistent failure surfaces |
| `NetworkError` | Connection dropped | Retry idempotent ops; investigate non-idempotent |
| `PollTimeoutError` | Training run exceeded `timeout` | Re-poll with `client.training.get(run_id)` |

## Retry behavior

The SDK already retries on transient failures with exponential backoff:

- **5xx responses** — up to 3 retries, backoff `1s → 2s → 4s`.
- **429 with `Retry-After` ≤ 120s** — auto-waits then retries.
- **Network errors** (connection reset, DNS blip) — same 3-retry policy.
- **Idempotency** — retried requests inherit the original
  `Idempotency-Key` header, so the backend dedupes.

Override on the Client:

```python
client = Client(timeout=30.0, max_retries=5)
```

## PaymentRequiredError details

```python
from pictograph.exceptions import PaymentRequiredError

try:
    client.training.create(dataset_name, export_name, pipeline_type="yolox")
except PaymentRequiredError as e:
    print(f"Need {e.required} credits, you have {e.remaining}")
    print(f"Top up at: {e.upgrade_url}")
```

`required`, `remaining`, and `upgrade_url` are populated from the
backend's `detail` block — fall back to plain `str(e)` if you only
need a user-facing message.

## ValidationError details

The backend returns a structured body listing every offending field:

```python
from pictograph.exceptions import ValidationError

try:
    client.annotations.save(image_id, [{"class": "person", "type": "bbox"}])
except ValidationError as e:
    print(e)            # human-readable summary
    print(e.errors)     # list of {"loc": [...], "msg": "...", "type": "..."}
```

The most common cause is the **`class` vs `name`** field mistake — the
backend rejects any annotation that uses `class`.

## PollTimeoutError + recovery

Long-running jobs (`training_pipeline`, batch auto-annotate, large
dataset imports) accept a `timeout` arg and raise `PollTimeoutError`
when it elapses. The job is **not cancelled** — it keeps running on the
backend.

```python
from pictograph.exceptions import PollTimeoutError

try:
    run, model = train_pipeline(client, "ds", pipeline="yolox", timeout=60.0)
except PollTimeoutError as e:
    # Pick up later
    run_id = e.run_id  # most poll errors carry the resource ID
    run = client.training.get(run_id)
    if run.status == "completed":
        model = client.models.get(run.model_id)
```

## Idempotency

For mutating ops the SDK auto-generates an `Idempotency-Key` header,
so retries are safe. Override per-call:

```python
client.images.upload(
    dataset_id=ds.id,
    file_path="x.jpg",
    idempotency_key="upload-x-jpg-2026-04-19",
)
```

Backend dedupes within 24h. Reusing the same key with a different body
returns `409 ConflictError` (`error_code: idempotency_conflict`).

See [Rate limits](/docs/rate-limits.md) for the per-tier limits and burst
behaviour.