Error handling
The exception hierarchy, when each error fires, and how to retry safely.
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:
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) |
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-Keyheader, so the backend dedupes.
Override on the Client:
client = Client(timeout=30.0, max_retries=5)
PaymentRequiredError details
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:
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.
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:
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 for the per-tier limits and burst behaviour.