Convilyn developers

Exceptions

Every error the SDK raises is a subclass of ConvilynError. Catch the base class for a single safety net, or catch a specific subclass when you want to branch on the failure mode.

Hierarchy

ConvilynError                       base — everything the SDK raises
├── AuthError                       no API key configured / malformed key
├── APIError                        HTTP 4xx / 5xx from the Convilyn API
│   ├── RateLimitError              HTTP 429
│   ├── S3UploadError               presigned upload step returned non-success
│   ├── RetryExhaustedError         retry policy ran out of attempts
│   ├── PlanRequiredError           HTTP 402 — your plan doesn't include this action
│   ├── QuotaExceededError          HTTP 402 — monthly cost cap reached
│   ├── InsufficientCreditsError    HTTP 402 — your balance cannot fund this run
│   ├── FreeTierBlockedError        HTTP 403 — a Free-plan gate refused it
│   ├── SpecNotPricedError          HTTP 409 — the workflow has no price (permanent)
│   └── ChargeUnavailableError      HTTP 409 — billing could not charge (transient)
├── JobFailedError                  conversion job reached status `failed`
├── JobTimeoutError                 conversion polling exceeded its timeout
├── GoalJobFailedError              goal workflow reached status `failed`
├── GoalJobTimeoutError             goal-workflow polling exceeded its timeout
├── GoalArtifactUnusableError       run succeeded, output cannot be handed back
└── UnderstandUnavailableError      no understanding pipeline for this input

ConvilynError

class ConvilynError(Exception): ...

Base class for everything the SDK raises. Catching ConvilynError is the recommended way to gate SDK calls behind a single error handler.

from convilyn import ConvilynError
 
try:
    client.convert.create_and_wait(file=file, target_format="pdf")
except ConvilynError as e:
    log.error("conversion failed", exc_info=e)

The SDK never raises a bare Exception — anything originating in the SDK is one of the classes below.

AuthError

Authentication or authorization failed before any HTTP call. Examples: no API key configured, malformed key prefix (ck_... expected).

APIError

The Convilyn API returned a non-success HTTP response. Surfaces the envelope {code, message, details} as attributes.

class APIError(ConvilynError):
    status_code: int
    code: str
    message: str
    details: dict[str, Any]

RateLimitError

HTTP 429 — the SDK or caller exceeded the rate limit. Subclass of APIError. Catching APIError covers this case too.

S3UploadError

The presigned upload step of a file upload returned a non-success status. Subclasses APIError so callers catching APIError see it, while still being distinguishable for callers who want to retry uploads with their own policy.

RetryExhaustedError

The retry policy ran out of attempts before the request succeeded. Wraps the final APIError so callers see the last server-side status / code.

class RetryExhaustedError(APIError):
    attempt_count: int   # total attempts including this one

PlanRequiredError

HTTP 402 with code TIER_REQUIRED (or the legacy variants PRO_TIER_REQUIRED, BUSINESS_TIER_REQUIRED, ENTERPRISE_TIER_REQUIRED) — your plan does not include this action. Surfaced by Pro-tier-gated endpoints such as client.workflows.fork, client.workflows.publish, and the builder chat-session create flow. Subclass of APIError.

class PlanRequiredError(APIError):
    upgrade_url: str | None     # in-app pricing CTA
    required_plan: str          # "pro" today; forward-compat for tier names

Recommended handling: catch and prompt the user to upgrade. Retrying without an upgrade will reproduce the same error.

QuotaExceededError

HTTP 402 with code QUOTA_EXCEEDED — the monthly cost cap was reached. Subclass of APIError.

class QuotaExceededError(APIError):
    estimated_micro_u: int | None      # this call's projected cost
    threshold_micro_u: int | None      # your tier's monthly cap
    upgrade_url: str | None

Pair with client.account.get_quota(...) to pre-flight before running an expensive workflow. See Quickstart §8.

InsufficientCreditsError

HTTP 402 with code INSUFFICIENT_CREDITS — your balance cannot fund this run. Subclass of APIError.

Not the same thing as QuotaExceededError, and they share a status code. A quota is a ceiling you were given; a balance is money you hold. A quota resets at the next period, a balance does not refill on its own — so the SDK gives them separate types rather than leaving you to branch on code.

class InsufficientCreditsError(APIError):
    required_credits: int | None
    available_credits: int | None
 
    @property
    def shortfall_credits(self) -> int | None: ...
except InsufficientCreditsError as exc:
    print(f"short by {exc.shortfall_credits} credits")   # may be None
except QuotaExceededError:
    ...                                                  # wait, or upgrade

Both operands are None when the server did not send them. Read that as unknown, never as zero — and shortfall_credits is None unless both are present, rather than guessing.

FreeTierBlockedError

HTTP 403 — a Free-plan gate refused the run before any charge. Subclass of APIError.

Two server codes land here, and they are one class because your next step is the same for both: leave the Free plan, or (for the cap) fund the run from a top-up.

  • spec_not_allowed_on_free — this workflow is not offered on Free.
  • free_cost_cap_exceeded — Free's monthly processing cap is spent.
class FreeTierBlockedError(APIError):
    upgrade_url: str | None     # in-app CTA — do not hardcode it

Branch on APIError.code when you want to say which; catch the type when you only need to know it is a plan gate rather than a server fault.

SpecNotPricedError

HTTP 409 with code SPEC_NOT_PRICED — this workflow has no price configured. Subclass of APIError.

Permanent for this workflow, and that is what separates it from ChargeUnavailableError on the same status code: retrying will not help, and no amount of credit changes it. Pick another workflow, or report it — a priced workflow reaching this state is a catalogue defect, not a caller mistake.

ChargeUnavailableError

HTTP 409 with code CHARGE_UNAVAILABLE — billing could not charge right now. Subclass of APIError.

Transient. The run was refused because the charge could not be recorded — not because it was unaffordable, and not because the workflow is unpriced. Retrying later is the correct response. The SDK does not retry it automatically, because a repeated charge attempt is your decision to make, not a transport concern.

JobFailedError

A conversion job (client.convert.wait, create_and_wait, …) finished with status failed. Attributes mirror the wire-side error envelope so you can correlate against backend logs.

class JobFailedError(ConvilynError):
    job_id: str
    processor_type: str
    code: str
    message: str
    detail: JobErrorDetail | None    # structured operands, when the server sent them

detail carries reason, plus sheet_count / faithful_targets where the refusal is about a specific input shape. It is None on most failures — the code says everything there is to say.

JobTimeoutError

A polling helper exceeded its timeout before the job reached a terminal status. The job is still alive on the backend — call client.convert.retrieve(job_id) to fetch its current state.

class JobTimeoutError(ConvilynError):
    job_id: str
    elapsed: float
    timeout: float

GoalJobFailedError

A goal workflow finished with status failed. Separate class from JobFailedError so you can distinguish conversion failures from goal-workflow failures in a try / except chain.

class GoalJobFailedError(ConvilynError):
    job_spec_id: str
    code: str | None
    message: str | None
    detail: GoalErrorDetail | None
    suggested_action: str | None
 
    @property
    def retryable(self) -> bool | None: ...

suggested_action is the server's own next step, so you never keep a second copy of the code-to-action mapping. retryable is a convenience over it, and it is tri-state on purpose: True, False, and None when the server said nothing. A plan ceiling is not retryable but is actionable, which is why the API sends an action rather than a boolean.

if exc.retryable:
    job = client.goals.retry(exc.job_spec_id)
elif exc.retryable is None:
    ...   # no guidance — decide from `code` yourself

detail says which limit, when there was one. PROCESSING_LIMIT covers four unrelated ceilings and the message is the same canned sentence for all of them:

except convilyn.GoalJobFailedError as exc:
    if exc.detail and exc.detail.reason == "ITERATION_LIMIT":
        print(f"stopped after {exc.detail.reached} of {exc.detail.limit} steps")

reason is one of ITERATION_LIMIT, TOKEN_BUDGET, REPEATED_TOOL_CALL, SCRATCHPAD_READ_BUDGET. limit / reached are None rather than 0 when a resumed run has no counter, so a missing number never reads as a real one. Treat an unfamiliar reason as absent — the server may know a ceiling your installed version does not.

Read this before re-running. client.goals.retry(job_spec_id) reuses the same job; starting the workflow again opens a new job spec and is charged from scratch.

GoalJobTimeoutError

A goal-workflow polling helper exceeded its timeout. Like JobTimeoutError but specific to goal workflows.

class GoalJobTimeoutError(ConvilynError):
    job_spec_id: str
    elapsed: float
    timeout: float
    reason: str        # "total" (whole budget) | "idle" (no progress)

The job is still alive on the backend — poll it again rather than starting a new one.

GoalArtifactUnusableError

The job ran, the platform is satisfied, and the output cannot be handed back.

This is not a failed job and not an argument mistake, which is why neither of the existing types fits. goals.extract() / understand() / to_markdown() all promise a shape; when the run finishes and no artifact of that shape can be returned, you did nothing wrong and have already paid. The most common cause is a partial run, where some tasks failed and the platform returns the result it does have rather than throwing your money away.

class GoalArtifactUnusableError(ConvilynError):
    job_spec_id: str
    kind: str            # "json" | "markdown"
    reason: str          # "missing" | "unparsable" | "too_large"
    job_status: str | None
    artifact_id: str | None
    size_bytes: int | None
    max_bytes: int | None
    detail: str | None

One class with three reasons rather than three classes, so a caller who does not recognise a future reason still catches it. Branch on reason:

from convilyn import GoalArtifactUnusableError
 
try:
    data = client.goals.understand(["file_abc"], schema=schema)
except GoalArtifactUnusableError as exc:
    if exc.reason == "too_large":
        log.warning(f"artifact {exc.size_bytes} > {exc.max_bytes} bytes")
    else:
        log.error(f"{exc.kind} artifact {exc.reason} (job {exc.job_status})")

UnderstandUnavailableError

client.goals.understand() (and to_markdown()) could not run: the platform has no pipeline for the shape you asked for, so no grounded, schema-validated result could be produced.

It is raised instead of returning an ungrounded or unvalidated answer — an answer the platform did not ground is never silently returned as though it had been. It is also raised before any credit is spent.

The shapes that reach it today are multi-file and mixed-modality requests: one call, one file, one modality is what the understanding pipelines serve.

try:
    md = client.goals.to_markdown(["file_abc"])
except convilyn.UnderstandUnavailableError:
    job = client.convert.create_and_wait(file=f, target_format="md")

Rendering a .docx / .pdf / .pptx / .xlsx to Markdown is deterministic file conversion — free on every plan, and what client.convert does. Fall back to that, to a workflow you authored (goals.run(user_workflow_id=...)), or to the fixed-schema goals.extract(...).

Catching patterns

Catch everything from the SDK:

try:
    ...
except ConvilynError as e:
    ...

Branch on transient vs permanent:

try:
    ...
except RateLimitError:
    # back off and retry later
except ChargeUnavailableError:
    # transient — billing could not record the charge; retry later
except (RetryExhaustedError, JobTimeoutError):
    # transient — caller should retry the workflow
except SpecNotPricedError:
    # permanent — this workflow has no price; pick another
except ConvilynError:
    # something else SDK-originated

Branch on why you cannot pay:

try:
    ...
except InsufficientCreditsError:
    # top up — a balance does not refill on its own
except QuotaExceededError:
    # wait for the next period, or upgrade
except FreeTierBlockedError as e:
    # leave the Free plan
    print(e.upgrade_url)
except PlanRequiredError as e:
    # this action needs a higher tier
    print(e.upgrade_url)

Order matters: all five subclass APIError, so an except APIError arm placed above them would swallow the distinction they exist to make.

Distinguish lanes:

try:
    ...
except JobFailedError as e:
    log.error(f"conversion job {e.job_id} failed: {e.code}")
except GoalJobFailedError as e:
    log.error(f"goal workflow {e.job_spec_id} failed: {e.code}")