Sign in Get started

Error handling

The exception hierarchy, when each error fires, and how to retry safely.

View as Markdown

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-limit retry_after); omitted when there is none.
  • detail - the legacy field, kept so existing integrations keep working. Prefer error.

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.

Copied to clipboard