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,
)
The error envelope
Every error the API returns uses one consistent JSON shape:
{
"error": {
"code": "not_found",
"message": "The requested resource was not found.",
"details": null
},
"detail": "The requested resource was not found."
}
error.code— a stable, machine-readable slug you can branch on without parsing the message. Status-derived by default (unauthorized,forbidden,not_found,conflict,validation_error,payment_required,rate_limited,internal_error, …), or a domain-specific slug (insufficient_credits,idempotency_conflict, …).error.message— a human-readable description.error.details— optional structured context (validation errors, the canonical credit-gate fields, a rate-limitretry_after, …); omitted when there is none.detail— the legacy field, preserved so existing integrations keep working. New code should prefererror.
Every ApiError exposes the slug as .code, so you can dispatch on the
machine code rather than the wording:
try:
client.datasets.get("my-dataset")
except ApiError as e:
if e.code == "not_found":
... # create it
elif e.code == "insufficient_credits":
show_upgrade(e.upgrade_url) # a PaymentRequiredError
else:
raise
.code is None only when talking to a server old enough to predate the
envelope; the typed subclasses (NotFoundError, PaymentRequiredError, …)
still dispatch correctly on the status code either way.
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:
# Amounts are integer micro-USD (1 USD = 1,000,000 µUSD)
print(f"Need ${e.credit_cost / 1e6:.2f}, you have ${e.credits_remaining / 1e6:.2f}")
print(f"Top up at: {e.upgrade_url}")
credit_cost, credits_remaining (both in µUSD — e.unit == "micro_usd"),
and upgrade_url are populated from the response’s canonical
error.details block (required_micro_usd / remaining_micro_usd / unit /
upgrade_url). 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.field_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 with error.code == "idempotency_conflict"
(also surfaced as e.code on the raised ConflictError).
See Rate limits for the per-tier limits and burst behaviour.