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)]
13pub enum SolariError {
14    /// HTTP 401/403 — the API key was missing, malformed, or rejected.
15    #[error("{message}")]
16    Auth { status: u16, message: String, code: Option<String> },
17
18    /// HTTP 402 (or a `plan_*` body) — the plan does not allow this.
19    #[error("{message}")]
20    Plan { status: u16, message: String, code: Option<String> },
21
22    /// HTTP 429 — the org is at its session cap. NOT retryable.
23    #[error("{message}")]
24    ConcurrencyLimit { status: u16, message: String, code: Option<String> },
25
26    /// HTTP 503 / body `no_capacity` — no host available (retryable).
27    #[error("{message}")]
28    NoCapacity { status: u16, message: String, code: Option<String> },
29
30    /// Any other non-2xx gateway response (incl. 404/405/409/501).
31    #[error("{message}")]
32    Gateway { status: u16, message: String, code: Option<String> },
33
34    /// A control-WS RPC replied `{ok:false}`.
35    #[error("{message}")]
36    Action { method: String, message: String, code: Option<String> },
37
38    /// A call (or connect) did not complete within its timeout.
39    #[error("Action \"{method}\" timed out after {timeout_ms}ms")]
40    Timeout { method: String, timeout_ms: u64 },
41
42    /// The control WebSocket is not open (never connected, or dropped).
43    #[error("{message}")]
44    Connection { message: String },
45
46    /// A git subcommand exited non-zero (message includes sub/exit/detail).
47    #[error("{message}")]
48    Git { message: String },
49
50    /// Anything else (bad response body, internal invariant).
51    #[error("{0}")]
52    Other(String),
53}
54
55impl SolariError {
56    pub(crate) fn connection(msg: impl Into<String>) -> Self {
57        SolariError::Connection { message: msg.into() }
58    }
59
60    /// The gateway HTTP status this error carries, when it is a gateway error.
61    pub fn status(&self) -> Option<u16> {
62        match self {
63            SolariError::Auth { status, .. }
64            | SolariError::Plan { status, .. }
65            | SolariError::ConcurrencyLimit { status, .. }
66            | SolariError::NoCapacity { status, .. }
67            | SolariError::Gateway { status, .. } => Some(*status),
68            _ => None,
69        }
70    }
71}
72
73/// Map a gateway HTTP response onto the appropriate typed error. Falls back to a
74/// generic `Gateway` for unrecognized statuses. Mirrors `mapGatewayError`.
75pub(crate) fn map_gateway_error(status: u16, body: Option<GatewayErrorBody>) -> SolariError {
76    let code = body.as_ref().and_then(|b| b.code.clone());
77    let message = body
78        .as_ref()
79        .and_then(|b| b.message.clone().or_else(|| b.error.clone()).or_else(|| b.code.clone()))
80        .unwrap_or_else(|| format!("Gateway request failed with status {status}"));
81    match status {
82        401 | 403 => SolariError::Auth { status, message, code },
83        402 => SolariError::Plan { status, message, code },
84        429 => SolariError::ConcurrencyLimit { status, message, code },
85        503 => SolariError::NoCapacity { status, message, code },
86        _ => SolariError::Gateway { status, message, code },
87    }
88}
89
90/// True when an error means the one-shot `/exec` route is not served (an older
91/// gateway) — the only case where `run` retries over the control WS. A real
92/// host/command failure is NOT route-unavailable and must propagate. Mirrors
93/// `isRouteUnavailable` (GatewayError with status 404/405/501).
94pub(crate) fn is_route_unavailable(err: &SolariError) -> bool {
95    matches!(err.status(), Some(404) | Some(405) | Some(501))
96}