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 onePlanRequiredError
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 namesRecommended 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 | NonePair 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 upgradeBoth 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 itBranch 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 themdetail 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: floatGoalJobFailedError
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` yourselfdetail 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 | NoneOne 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-originatedBranch 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}")