Skip to main content

solari/
error.rs

1//! Typed error hierarchy for the Solari SDK.
2//!
3//! Mirrors the reference `errors.ts`: gateway HTTP status codes and control-WS
4//! RPC failures are mapped onto a single enum so callers can `match` broadly.
5//! `SolariError` is the base type carrying every failure this crate raises.
6
7use crate::types::GatewayErrorBody;
8
9/// Every error raised by the SDK. Corresponds to the `SolariError` hierarchy in
10/// the TypeScript reference (AuthError / PlanError / ConcurrencyLimitError /
11/// NoCapacityError / GatewayError / ActionError / TimeoutError / ConnectionError).
12#[derive(Debug, thiserror::Error)]
13// Non-exhaustive so a future variant is a non-breaking addition for downstream
14// crates that match on this type. It does NOT constrain matches inside this
15// crate (Rust only enforces the wildcard arm on out-of-crate matches), so keep
16// it: its whole purpose is to make the next variant free rather than breaking.
17#[non_exhaustive]
18pub enum SolariError {
19    /// HTTP 401/403 — the API key was missing, malformed, or rejected.
20    #[error("{message}")]
21    Auth { status: u16, message: String, code: Option<String> },
22
23    /// HTTP 402 (or a `plan_*` body) — the plan does not allow this.
24    #[error("{message}")]
25    Plan { status: u16, message: String, code: Option<String> },
26
27    /// HTTP 429 — the org is at its session cap. NOT retryable.
28    #[error("{message}")]
29    ConcurrencyLimit { status: u16, message: String, code: Option<String> },
30
31    /// HTTP 503 / body `no_capacity` — no host available (retryable).
32    #[error("{message}")]
33    NoCapacity { status: u16, message: String, code: Option<String> },
34
35    /// Any other non-2xx gateway response (incl. 404/405/409/501).
36    #[error("{message}")]
37    Gateway { status: u16, message: String, code: Option<String> },
38
39    /// A control-WS RPC replied `{ok:false}`.
40    #[error("{message}")]
41    Action { method: String, message: String, code: Option<String> },
42
43    /// A call (or connect) did not complete within its timeout.
44    #[error("Action \"{method}\" timed out after {timeout_ms}ms")]
45    Timeout { method: String, timeout_ms: u64 },
46
47    /// The control WebSocket is not open (never connected, or dropped).
48    #[error("{message}")]
49    Connection { message: String },
50
51    /// A git subcommand exited non-zero (message includes sub/exit/detail).
52    #[error("{message}")]
53    Git { message: String },
54
55    /// Anything else (bad response body, internal invariant).
56    #[error("{0}")]
57    Other(String),
58    /// A caller passed an invalid argument (e.g. a blank session id). Distinct
59    /// from `Other`: this is the caller's input to fix, not an unexpected
60    /// server response.
61    #[error("{message}")]
62    Validation { message: String },
63}
64
65impl SolariError {
66    pub(crate) fn connection(msg: impl Into<String>) -> Self {
67        SolariError::Connection { message: msg.into() }
68    }
69
70    /// The gateway HTTP status this error carries, when it is a gateway error.
71    pub fn status(&self) -> Option<u16> {
72        match self {
73            SolariError::Auth { status, .. }
74            | SolariError::Plan { status, .. }
75            | SolariError::ConcurrencyLimit { status, .. }
76            | SolariError::NoCapacity { status, .. }
77            | SolariError::Gateway { status, .. } => Some(*status),
78            _ => None,
79        }
80    }
81}
82
83/// Map a gateway HTTP response onto the appropriate typed error. Falls back to a
84/// generic `Gateway` for unrecognized statuses. Mirrors `mapGatewayError`.
85pub(crate) fn map_gateway_error(status: u16, body: Option<GatewayErrorBody>) -> SolariError {
86    let code = body.as_ref().and_then(|b| b.code.clone());
87    let message = body
88        .as_ref()
89        .and_then(|b| b.message.clone().or_else(|| b.error.clone()).or_else(|| b.code.clone()))
90        .unwrap_or_else(|| format!("Gateway request failed with status {status}"));
91    match status {
92        401 | 403 => SolariError::Auth { status, message, code },
93        402 => SolariError::Plan { status, message, code },
94        429 => SolariError::ConcurrencyLimit { status, message, code },
95        503 => SolariError::NoCapacity { status, message, code },
96        _ => SolariError::Gateway { status, message, code },
97    }
98}
99
100/// True when an error means the one-shot `/exec` route is not served (an older
101/// gateway) — the only case where `run` retries over the control WS. A real
102/// host/command failure is NOT route-unavailable and must propagate. Mirrors
103/// `isRouteUnavailable` (GatewayError with status 404/405/501).
104pub(crate) fn is_route_unavailable(err: &SolariError) -> bool {
105    matches!(err.status(), Some(404) | Some(405) | Some(501))
106}