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 — the API key was missing, malformed, or rejected.
20    #[error("{message}")]
21    Auth { status: u16, message: String, code: Option<String> },
22
23    /// The plan does not allow this: HTTP 402 (`FeatureRequiresPlan`, `InsufficientCredit`) or 403 `NotEntitled` (the plan
24    /// does not include sandboxes/desktops). `status` is the status the gateway really sent.
25    #[error("{message}")]
26    Plan { status: u16, message: String, code: Option<String> },
27
28    /// HTTP 429 `ConcurrencyLimitExceeded` (or a 429 with no code) — the org is at its session cap. NOT retryable. Other 429s
29    /// (`TooManyExecutions`, a per-sandbox limit, `ObjectLimit`, the template caps) are `Gateway` errors with the gateway's `code`.
30    #[error("{message}")]
31    ConcurrencyLimit { status: u16, message: String, code: Option<String> },
32
33    /// HTTP 503 `NoCapacity` / `StartTimeout` (or a 503 with no code) — no host available (retryable). Other 503s
34    /// (`SandboxUnavailable`, `StoreUnavailable`, `AdmissionUnavailable`, `DependencyUnavailable`) are `Gateway` errors: the
35    /// sandbox, store or admission service is unavailable, which is not a capacity problem.
36    #[error("{message}")]
37    NoCapacity { status: u16, message: String, code: Option<String> },
38
39    /// Any other non-2xx gateway response (incl. 404/405/409/501).
40    #[error("{message}")]
41    Gateway { status: u16, message: String, code: Option<String> },
42
43    /// A control-WS RPC replied `{ok:false}`.
44    #[error("{message}")]
45    Action { method: String, message: String, code: Option<String> },
46
47    /// A call (or connect) did not complete within its timeout.
48    #[error("Action \"{method}\" timed out after {timeout_ms}ms")]
49    Timeout { method: String, timeout_ms: u64 },
50
51    /// The control WebSocket is not open (never connected, or dropped).
52    #[error("{message}")]
53    Connection { message: String },
54
55    /// A git subcommand exited non-zero (message includes sub/exit/detail).
56    #[error("{message}")]
57    Git { message: String },
58
59    /// Anything else (bad response body, internal invariant).
60    #[error("{0}")]
61    Other(String),
62    /// A caller passed an invalid argument (e.g. a blank session id). Distinct
63    /// from `Other`: this is the caller's input to fix, not an unexpected
64    /// server response.
65    #[error("{message}")]
66    Validation { message: String },
67}
68
69impl SolariError {
70    pub(crate) fn connection(msg: impl Into<String>) -> Self {
71        SolariError::Connection { message: msg.into() }
72    }
73
74    /// The gateway HTTP status this error carries, when it is a gateway error.
75    pub fn status(&self) -> Option<u16> {
76        match self {
77            SolariError::Auth { status, .. }
78            | SolariError::Plan { status, .. }
79            | SolariError::ConcurrencyLimit { status, .. }
80            | SolariError::NoCapacity { status, .. }
81            | SolariError::Gateway { status, .. } => Some(*status),
82            _ => None,
83        }
84    }
85}
86
87/// Map a gateway HTTP response onto the appropriate typed error. Falls back to a
88/// generic `Gateway` for unrecognized statuses. Mirrors `mapGatewayError`.
89pub(crate) fn map_gateway_error(status: u16, body: Option<GatewayErrorBody>) -> SolariError {
90    let code = body.as_ref().and_then(|b| b.code.clone());
91    let message = body
92        .as_ref()
93        .and_then(|b| b.message.clone().or_else(|| b.error.clone()).or_else(|| b.code.clone()))
94        .unwrap_or_else(|| default_gateway_message(status));
95    // `code` is the discriminator (PROTOCOL.md section 4); the status alone is not enough because one status carries
96    // several meanings. A response with no code keeps the type its status has always had.
97    match (status, code.as_deref()) {
98        (401, _) => SolariError::Auth { status, message, code },
99        (402, _) => SolariError::Plan { status, message, code },
100        // The gateway's only JSON 403 is NotEntitled (the plan does not include sandboxes/desktops).
101        (403, Some("NotEntitled")) => SolariError::Plan { status, message, code },
102        (429, None | Some("ConcurrencyLimitExceeded")) => SolariError::ConcurrencyLimit { status, message, code },
103        (503, None | Some("NoCapacity" | "StartTimeout")) => SolariError::NoCapacity { status, message, code },
104        _ => SolariError::Gateway { status, message, code },
105    }
106}
107
108/// True when an error means the one-shot `/exec` route is not served (an older
109/// gateway) — the only case where `run` retries over the control WS. A real
110/// host/command failure is NOT route-unavailable and must propagate. Mirrors
111/// `isRouteUnavailable` (GatewayError with status 404/405/501), except that a 404 only counts when it is not a
112/// gateway answer: a missing route comes back as a plain-text "404 Not Found" (no JSON body, so the message is the
113/// default one), while a deleted or unknown sandbox comes back as JSON (`{"error":"Not found"}`). The latter must
114/// propagate, or the fallback hides it behind "not connected".
115pub(crate) fn is_route_unavailable(err: &SolariError) -> bool {
116    match err {
117        SolariError::Gateway { status: 404, message, code: None } => *message == default_gateway_message(404),
118        _ => matches!(err.status(), Some(405) | Some(501)),
119    }
120}
121
122/// The gateway refusing a command stream because the sandbox is on the shared isolation tier.
123pub(crate) fn is_shared_refusal(err: &SolariError) -> bool {
124    match err {
125        SolariError::Action { code: Some(c), .. } if c == "NotAvailableOnSharedIsolation" => true,
126        other => {
127            let m = other.to_string();
128            m.contains("NotAvailableOnSharedIsolation") || m.contains("not available on a shared")
129        }
130    }
131}
132
133fn default_gateway_message(status: u16) -> String {
134    format!("Gateway request failed with status {status}")
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    // A 404 from /exec is "route not served" only when it is not a gateway answer: a deleted sandbox is a JSON 404
142    // and must reach the caller instead of falling back to the control channel ("not connected").
143    #[test]
144    fn route_unavailable_is_not_a_json_404() {
145        let plain_404 = map_gateway_error(404, None);
146        let json_404 = map_gateway_error(
147            404,
148            Some(GatewayErrorBody { error: Some("Not found".into()), ..Default::default() }),
149        );
150        assert!(is_route_unavailable(&plain_404), "a plain-text 404 means no such route");
151        assert!(!is_route_unavailable(&json_404), "a JSON 404 means the sandbox is gone");
152        assert!(is_route_unavailable(&map_gateway_error(405, None)));
153        assert!(is_route_unavailable(&map_gateway_error(501, None)));
154        assert!(!is_route_unavailable(&map_gateway_error(500, None)));
155        assert!(!is_route_unavailable(&SolariError::Other("x".into())));
156    }
157
158    #[derive(Debug, PartialEq)]
159    enum Kind {
160        Auth,
161        Plan,
162        Concurrency,
163        NoCapacity,
164        Plain,
165    }
166
167    fn kind(e: &SolariError) -> Kind {
168        match e {
169            SolariError::Auth { .. } => Kind::Auth,
170            SolariError::Plan { .. } => Kind::Plan,
171            SolariError::ConcurrencyLimit { .. } => Kind::Concurrency,
172            SolariError::NoCapacity { .. } => Kind::NoCapacity,
173            SolariError::Gateway { .. } => Kind::Plain,
174            other => panic!("not a gateway error: {other:?}"),
175        }
176    }
177
178    /// Gateway status + body code -> typed error (PROTOCOL.md section 4).
179    #[test]
180    fn maps_by_code_first() {
181        let cases: &[(&str, u16, Option<&str>, Kind)] = &[
182            ("401", 401, Some("Unauthorized"), Kind::Auth),
183            ("402-plan", 402, Some("FeatureRequiresPlan"), Kind::Plan),
184            ("402-credit", 402, Some("InsufficientCredit"), Kind::Plan),
185            ("403-not-entitled", 403, Some("NotEntitled"), Kind::Plan),
186            ("403-other", 403, Some("Forbidden"), Kind::Plain),
187            ("404", 404, Some("NotFound"), Kind::Plain),
188            ("409-sandbox-stopped", 409, Some("SandboxStopped"), Kind::Plain),
189            ("410-gone", 410, Some("SandboxGone"), Kind::Plain),
190            ("429-concurrency-cap", 429, Some("ConcurrencyLimitExceeded"), Kind::Concurrency),
191            ("429-no-code", 429, None, Kind::Concurrency),
192            ("429-too-many-executions", 429, Some("TooManyExecutions"), Kind::Plain),
193            ("429-object-limit", 429, Some("ObjectLimit"), Kind::Plain),
194            ("500-internal", 500, Some("InternalError"), Kind::Plain),
195            ("501-not-supported", 501, Some("NotSupported"), Kind::Plain),
196            ("503-no-capacity", 503, Some("NoCapacity"), Kind::NoCapacity),
197            ("503-start-timeout", 503, Some("StartTimeout"), Kind::NoCapacity),
198            ("503-no-code", 503, None, Kind::NoCapacity),
199            ("503-sandbox-unavailable", 503, Some("SandboxUnavailable"), Kind::Plain),
200            ("503-store-unavailable", 503, Some("StoreUnavailable"), Kind::Plain),
201            ("503-admission-unavailable", 503, Some("AdmissionUnavailable"), Kind::Plain),
202            ("503-dependency-unavailable", 503, Some("DependencyUnavailable"), Kind::Plain),
203            ("507-insufficient-capacity", 507, Some("InsufficientCapacity"), Kind::Plain),
204        ];
205        for (id, status, code, want) in cases {
206            let body = GatewayErrorBody { code: code.map(str::to_string), ..Default::default() };
207            let e = map_gateway_error(*status, Some(body));
208            assert_eq!(&kind(&e), want, "errors/{id}");
209            assert_eq!(e.status(), Some(*status), "errors/{id}: the status the gateway sent is kept");
210            let kept = match &e {
211                SolariError::Auth { code, .. }
212                | SolariError::Plan { code, .. }
213                | SolariError::ConcurrencyLimit { code, .. }
214                | SolariError::NoCapacity { code, .. }
215                | SolariError::Gateway { code, .. } => code.clone(),
216                _ => unreachable!(),
217            };
218            assert_eq!(kept.as_deref(), *code, "errors/{id}: the gateway's code is kept");
219        }
220    }
221}