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.
Source: exceptions.py
Hierarchy
PictographError
├── ConfigurationError - missing API key, invalid base URL
├── PollTimeoutError - long-running job (training, batch SAM3) didn't finish
├── NetworkError - connection / DNS / TLS failure
│ └── RequestTimeoutError - request exceeded the SDK's timeout budget
└── ApiError - the server answered with an error status
├── AuthError - 401 (bad / missing / revoked key)
├── PaymentRequiredError - 402 (out of credits)
├── ForbiddenError - 403 (role lacks permission)
├── NotFoundError - 404 (resource missing)
├── ConflictError - 409 (duplicate name, optimistic-lock fail)
├── ValidationError - 400 / 422 (payload rejected)
├── RateLimitError - 429 (per-key rate cap hit)
└── ServerError - 5xx (transient backend failure)
The nesting matters: except ApiError catches every HTTP error including
NotFoundError, and except NetworkError catches RequestTimeoutError. Every name
imports from pictograph.exceptions.
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 slug to branch on without parsing the message. Status-derived by default (unauthorized,forbidden,not_found,conflict,validation_error,payment_required,rate_limited,internal_error), or domain-specific (insufficient_credits,idempotency_conflict).error.message- a human-readable description.error.details- optional structured context (field errors, credit-gate fields, a rate-limitretry_after); omitted when there is none.detail- the legacy field, kept so existing integrations keep working. Prefererror.
Every ApiError exposes the slug as .code, so you can dispatch on the machine code
rather than the wording:
try:
client.datasets.get(
name="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 against a server predating the envelope; the typed subclasses
still dispatch 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 directory | 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
Source: _http/retry.py
The SDK already retries transient failures with exponential backoff:
- 5xx - up to 3 retries, backoff
1s → 2s → 4s. - 429 with
Retry-After≤ 120s - waits, then retries. - Network errors (connection reset, DNS blip) - same 3-retry policy.
- Idempotency - a retried request reuses the original
Idempotency-Key, so the server dedupes it.
client = Client(timeout=30.0, max_retries=5)
PaymentRequiredError details
from pictograph.exceptions import PaymentRequiredError
try:
client.training.create(
dataset_name="road-signs",
export_name="road-signs-v1",
pipeline_type="yolox",
name="run-1",
)
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 and credits_remaining are µUSD (e.unit == "micro_usd"); both, plus
upgrade_url, come from error.details. Use str(e) if you only need a user-facing
message.
ValidationError details
The response body lists every offending field:
from pictograph.exceptions import ValidationError
try:
client.annotations.save(
dataset_name="road-signs",
image="img-001.jpg",
annotations=[{"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 mistake: an annotation that uses
class is rejected.
PollTimeoutError and recovery
Long-running jobs (client.training.create, batch auto-annotate, large imports) accept
a timeout and raise PollTimeoutError when it elapses. The job is not cancelled -
it keeps running server-side.
from pictograph.exceptions import PollTimeoutError
# Start it without blocking, so you keep the id, then poll on your own terms.
run = client.training.create(
dataset_name="ds",
export_name="ds-v1",
pipeline_type="yolox",
name="ds-detector",
wait=False,
)
try:
run = client.training.wait_for_completion(
run_id=run.id,
timeout=60.0,
)
except PollTimeoutError:
# Still running server-side - pick it up later with the id you kept.
run = client.training.get(
run_id=run.id,
)
Idempotency
Source: _http/idempotency.py
Every mutating call carries an Idempotency-Key header, so a retry - yours or the
SDK’s - is never applied twice. Keys dedupe within 24h; reusing one with a different
body returns 409 ConflictError with e.code == "idempotency_conflict".
See Rate limits for the per-tier budgets.