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}