Skip to content

Error categories ​

proxxx returns typed error enums in the domain layer (audit) so callers can match on the failure shape instead of grepping prose. The application layer wraps everything in anyhow::Error for the ? ergonomics; the CLI exit-code path walks the anyhow chain via downcast_ref::<T>() to surface the right exit code.

There are three families of typed error you'll encounter:

  1. api::ApiError — every HTTP error from PVE / PBS.
  2. config::ConfigError — configuration loading / parsing.
  3. Refusal errors — app::preflight::PreflightRefusal (risk gate) and incident::FreezeRefusal (incident lockdown).

The ApiError enum ​

rust
pub enum ApiError {
    /// 401. Token wrong, password expired, ticket expired post-suspend.
    /// Caller may force re-auth and retry, OR surface "credentials rejected".
    Unauthorized(String),

    /// 403. Token is valid but lacks privilege for this operation.
    /// Caller should NOT retry — surface a clear "permission denied".
    Forbidden(String),

    /// 404. The named resource doesn't exist.
    /// Caller can choose: silently treat as already-gone (during
    /// teardown) or surface "guest 100 not found".
    NotFound(String),

    /// 429 / 502 / 503 / 504 — transient cluster overload.
    /// Read-only GET requests retry all four automatically; mutating
    /// requests (POST, PUT, DELETE) retry ONLY 429 and 503 with a
    /// `Retry-After` header (#348). On 502, 504, or 503 without
    /// `Retry-After`, a mutation fails immediately and the hint warns
    /// that PVE may still have executed it (`check proxxx tasks`).
    RateLimited(String),

    /// Body exceeded our 32 MiB cap. Misbehaving node or hostile
    /// upstream. Caller should refuse, not parse partial JSON.
    PayloadTooLarge(String),

    /// 594 / 595 / 596 / 599 — Proxmox-specific statuses for upstream
    /// service hang (pvestatd freeze, NFS-backed storage hang).
    /// Distinguished because the right caller behaviour is "show
    /// degraded banner", not retry.
    StorageHang(String),

    /// Network / TLS / connection failure. Includes timeouts, DNS
    /// failures, certificate errors. Read-only GET requests retry
    /// timeouts and connect errors; mutations retry ONLY connect
    /// errors (`is_connect() && !is_timeout()`, excluding TLS/cert
    /// failures) and never retry a timeout (#348).
    Transport(String),

    /// JSON deserialization failed. PVE returned non-JSON, schema drift,
    /// or hostile payload. Caller should NOT retry; treat as poisoned.
    Parse { path: String, body_preview: String },

    /// Uncategorised HTTP status (anything not matched above —
    /// 4xx-not-401/403/404, 5xx-not-429/502/503/504/594-599).
    Other { status: u16, path: String, body: String },
}

The ConfigError enum ​

rust
pub enum ConfigError {
    /// No `config.toml` found on disk. Caller can offer to scaffold.
    NotFound(PathBuf),

    /// File exists but read failed (permissions, IO error).
    Io(std::io::Error),

    /// TOML parse failed OR a required field is missing / malformed.
    Toml(String),
}

All three map to exit 3 today; splitting them to distinct codes later is an additive (minor) bump as long as 3 stays in the set.

Refusal errors (typed, non-API) ​

Two refusal kinds short-circuit the dispatch chain with their own exit codes:

rust
/// Pre-flight risk gate. Fires from per-guest pre-flight
/// (running guest, HA-managed, tagged prod, etc.) and from
/// state-apply pre-flight (non-empty pool delete, root-role ACL
/// delete, shared-storage delete, batch ≥ 50).
pub struct PreflightRefusal;
impl PreflightRefusal { const EXIT_CODE: i32 = 6; }

/// Incident lockdown active. Fired by every `PxClient::{post,put,delete}`
/// when `proxxx incident freeze` is in effect — even by user code that
/// didn't think to check. Cleared by `proxxx incident thaw` or by TTL.
pub struct FreezeRefusal;
impl FreezeRefusal { const EXIT_CODE: i32 = 8; }

These are carried through anyhow::Error and downcast in main.rs before any ApiError::exit_code() mapping, so they always win.

How to dispatch ​

rust
match operation.await {
    Ok(value) => Ok(value),
    Err(e) => {
        // Refusal-typed errors first — they out-rank API errors.
        if e.downcast_ref::<PreflightRefusal>().is_some() {
            return bail!("refused — re-run with --allow-risk if intentional");
        }
        if e.downcast_ref::<FreezeRefusal>().is_some() {
            return bail!("cluster frozen — run `proxxx incident thaw` first");
        }
        // Then walk the chain for ApiError variants.
        match e.downcast_ref::<ApiError>() {
            Some(ApiError::Unauthorized(_))   => prompt_reauth(),
            Some(ApiError::Forbidden(_))      => bail!("you can't do that here"),
            Some(ApiError::NotFound(_))       => silently_skip(),
            Some(ApiError::RateLimited(_))    => back_off_and_retry(),
            Some(ApiError::StorageHang(node)) => show_degraded_banner(node),
            _                                 => Err(e),
        }
    }
}

ApiError: Into<anyhow::Error> so callers that don't care about variants keep working unchanged via ?.

Mapping to exit codes ​

See Exit codes for the full table:

Error typeExit code
ApiError::Unauthorized, ApiError::Forbidden4
ApiError::NotFound5
ApiError::RateLimited, ApiError::StorageHang7
ApiError::Parse, ApiError::Transport, ApiError::PayloadTooLarge, ApiError::Other1
ConfigError::* (all three variants)3
PreflightRefusal6
FreezeRefusal8

JSON error output (--format json and --format json-envelope) ​

When a CLI command fails under --format json, proxxx prints a one-element JSON array to stdout before exiting with the mapped code:

json
[
  {
    "error": "outermost context: middle layer: innermost root cause",
    "causes": [
      "outermost context",
      "middle layer",
      "innermost root cause"
    ],
    "exit_code": 1,
    "status": "fatal_error",
    "hint": "optional actionable hint when a typed error provides one"
  }
]
  • error: the full formatted chain (format!("{e:#}")), matching text mode.
  • causes: array of strings, one per element of e.chain(), outermost first.
  • exit_code: the integer passed to std::process::exit.
  • status: always "fatal_error".
  • hint: present when a typed ApiError in the chain provides actionable_hint().

When --format json-envelope is active, the same one-element error array is wrapped in the standard versioned envelope from util::format:

json
{
  "proxxx": "0.14.0",
  "data": [
    {
      "error": "...",
      "causes": ["..."],
      "exit_code": 1,
      "status": "fatal_error"
    }
  ]
}

For ApiError::Transport, ApiError::from_transport walks the full std::error::Error::source() chain of the underlying reqwest::Error so TLS verification errors, DNS lookup failures, and OS-level socket connect errors (such as Connection refused) are preserved in both error and causes.

Where it's mapped ​

The canonical ApiError production site is src/api/error.rs — ApiError::from_response reads HTTP status + body and produces the right variant. The CLI exit-code dispatch lives in src/main.rs and walks the anyhow chain.

Adding a new ApiError variant requires:

  1. Add it to the enum with a #[error("…")] message.
  2. Map at the production site (from_response or callers).
  3. Update exit codes if the new variant warrants its own exit code.
  4. UI / CLI callers that want differentiated handling .downcast_ref::<ApiError>() on the anyhow chain.

Why typed errors ​

Pre-fix, every API call returned anyhow::Result<T>. Anyhow is perfect for the application boundary but ruinous in the domain layer because callers can't match on categorical failures. The TUI couldn't distinguish "credentials rejected — re-auth?" modal from a "rate-limited, waiting" toast because everything was string-grepped anyhow::Error.

Closed enums let exhaustive matching catch the cases at compile time. New variants force callers to acknowledge or fall through explicitly.

Surfacing in the TUI ​

The TUI does not map these categories to separate widgets. What it shows:

  • Red status-line banner, for 5 seconds, with the error text: a failed background fetch, and a failed action such as start, stop, delete, migrate or a snapshot (the message names the action and the guest).
  • QUORUM LOST — STALE DATA while the last /cluster/status reported no quorum.
  • STALE DATA (Ns) when the last successful refresh is more than 10 seconds old.
  • A confirm modal before a destructive action, describing it.

Everything is also written to the log file. The categories above matter on the CLI, where they decide the exit code.

See also ​

  • Error handling architecture — design rationale and the boundary between domain and application errors
  • Exit codes — stable scripting contract
  • Explain command — proxxx explain <error-id> prints cause + numbered fixes + diagnostic commands for every typed error

Released under the MIT License. · Privacy & legal