Skip to content
ocx
install

Errors & credentials

The error model: exit code is the category

Section titled “The error model: exit code is the category”

Every exception the SDK raises from a process failure derives from the exit code alone — never from matching stderr text, which is exactly the kind of heuristic that breaks quietly when a message’s wording changes upstream. OcxError is the root; every subclass’s __str__ carries an actionable next step, not just a fact, because the message is what shows up in a CI log and a bare fact leaves the reader guessing what to do about it.

from ocx_sdk import DataError
error = DataError(65, ["ocx", "add", "--bad-flag"], stderr="unexpected identifier")
assert "next step" not in str(error) # every message names one; this just isn't it verbatim
assert "stderr:" in str(error)

Two catch shapes cover almost everything:

  • except OcxExecutionError — a process that failed and one that never finished (a non-zero exit and a timeout share this parent), the shape most callers actually want.
  • except OcxProcessError — a non-zero exit specifically, with .exit_code (a plain int, because a signal-killed process exits with a code ocx never assigns — 137 for SIGKILL, say — and building an error object must never itself raise) and .retryable, which reports what ocx said about the failure (fixed: True only for exit 75), independent of whatever RetryPolicy a caller happened to pass.

The full exit-code-to-exception table, and which codes retry by default, lives on Environment & exit codes. BootstrapError and its children (DownloadError, ChecksumMismatchError, DistManifestError, UnsupportedPlatformError) are a separate, non-process branch under OcxError — failures while resolving, downloading, or installing a binary, before any typed command ever runs.

Since ocx 0.6.1 a hard failure that printed no report prints a structured error envelope on stdout instead — {schema_version, command, exit_code, error: {kind, detail?, message, remediation?, context}}, a contract frozen separately from the report schemas. The exit code is still the category; the envelope is the detail, and error_envelope is how a caller reads it off a caught OcxProcessError:

from ocx_sdk import DataError, error_envelope
error = DataError(
65,
["ocx", "package", "claim", "acme/widget"],
stderr="",
stdout='{"schema_version": 1, "command": "package claim", "exit_code": 65, '
'"error": {"kind": "data_error", "detail": "package_already_claimed", '
'"message": "package already claimed: p/acme/widget.json exists on main for acme/widget", "context": {}}}',
)
envelope = error_envelope(error)
assert envelope is not None
assert (envelope.kind, envelope.detail) == ("data_error", "package_already_claimed")

None when stdout is empty, not JSON, or a report — the dual of partial_report, which recovers the report a report-then-fail command wrote; a failure never carries both. kind is the exit-code category’s serde name; detail is the fine-grained variant slug, frozen by ocx so a caller can branch on it — "package_already_claimed" is how an idempotent CI step recognizes the claim it already made. Not every failure path assigns one yet, so treat detail as optional; message is for humans and never a branch.

Exit 86, ForgeCapabilityUnavailableError, joins the table with 0.6.1: a forge write whose credential is valid but whose target refuses job-token push. It is never retryable — the fix is an administrator’s allowlist entry, not another attempt.

Verification makes install and pull fail where they used to pass

Section titled “Verification makes install and pull fail where they used to pass”

ocx 0.6 verifies a package’s Sigstore signature before installing it, whenever a [[trust.policy]] in the host’s config.toml covers that package. This SDK’s 0.2.0 floor bump to ocx 0.6.0 is what puts that gate in every caller’s path, so package.install and package.pull can now raise where the identical call succeeded before — no code change on your side, only a newer binary underneath. The failures are the ordinary exit-code-mapped ones: DataError (65) for a signature that did not hold up, PermissionDeniedError (77) for an identity or issuer the policy refuses, NotFoundError (79) when a covered package carries no signature at all, and TransparencyLogUnavailableError (83) when Rekor is unreachable. None of them retry by default — the default retry_on is exit 75 alone — and 83 in particular is deliberately excluded, because retrying a transparency-log outage amplifies it for everyone else.

The trust policy is ambient host state: it lives in a config file the SDK does not own, read, or write, so the SDK cannot tell you in advance whether the gate applies to your packages. That is why this is documented rather than detected. To find out, run the install and catch the error.

The verify parameter on both methods is a three-state override — True demands the check, False (--no-verify) skips it, None leaves ocx’s own default, which is on. True is not enforcement: against a package no policy covers there is nothing to verify against, and the flag is a documented no-op rather than a promise that unsigned content will be refused. False is the only way to skip verification through this SDK: the ambient OCX_NO_VERIFY escape hatch is neutralized on every spawn, so an opt-out has to be visible at the call site rather than in whatever exported a variable three CI layers up.

Continues on Errors & credentials: PackageRef: carried, never parsed.