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
122fn default_gateway_message(status: u16) -> String {
123    format!("Gateway request failed with status {status}")
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    // A 404 from /exec is "route not served" only when it is not a gateway answer: a deleted sandbox is a JSON 404
131    // and must reach the caller instead of falling back to the control channel ("not connected").
132    #[test]
133    fn route_unavailable_is_not_a_json_404() {
134        let plain_404 = map_gateway_error(404, None);
135        let json_404 = map_gateway_error(
136            404,
137            Some(GatewayErrorBody { error: Some("Not found".into()), ..Default::default() }),
138        );
139        assert!(is_route_unavailable(&plain_404), "a plain-text 404 means no such route");
140        assert!(!is_route_unavailable(&json_404), "a JSON 404 means the sandbox is gone");
141        assert!(is_route_unavailable(&map_gateway_error(405, None)));
142        assert!(is_route_unavailable(&map_gateway_error(501, None)));
143        assert!(!is_route_unavailable(&map_gateway_error(500, None)));
144        assert!(!is_route_unavailable(&SolariError::Other("x".into())));
145    }
146
147    #[derive(Debug, PartialEq)]
148    enum Kind {
149        Auth,
150        Plan,
151        Concurrency,
152        NoCapacity,
153        Plain,
154    }
155
156    fn kind(e: &SolariError) -> Kind {
157        match e {
158            SolariError::Auth { .. } => Kind::Auth,
159            SolariError::Plan { .. } => Kind::Plan,
160            SolariError::ConcurrencyLimit { .. } => Kind::Concurrency,
161            SolariError::NoCapacity { .. } => Kind::NoCapacity,
162            SolariError::Gateway { .. } => Kind::Plain,
163            other => panic!("not a gateway error: {other:?}"),
164        }
165    }
166
167    /// Gateway status + body code -> typed error (PROTOCOL.md section 4).
168    #[test]
169    fn maps_by_code_first() {
170        let cases: &[(&str, u16, Option<&str>, Kind)] = &[
171            ("401", 401, Some("Unauthorized"), Kind::Auth),
172            ("402-plan", 402, Some("FeatureRequiresPlan"), Kind::Plan),
173            ("402-credit", 402, Some("InsufficientCredit"), Kind::Plan),
174            ("403-not-entitled", 403, Some("NotEntitled"), Kind::Plan),
175            ("403-other", 403, Some("Forbidden"), Kind::Plain),
176            ("404", 404, Some("NotFound"), Kind::Plain),
177            ("409-sandbox-stopped", 409, Some("SandboxStopped"), Kind::Plain),
178            ("410-gone", 410, Some("SandboxGone"), Kind::Plain),
179            ("429-concurrency-cap", 429, Some("ConcurrencyLimitExceeded"), Kind::Concurrency),
180            ("429-no-code", 429, None, Kind::Concurrency),
181            ("429-too-many-executions", 429, Some("TooManyExecutions"), Kind::Plain),
182            ("429-object-limit", 429, Some("ObjectLimit"), Kind::Plain),
183            ("500-internal", 500, Some("InternalError"), Kind::Plain),
184            ("501-not-supported", 501, Some("NotSupported"), Kind::Plain),
185            ("503-no-capacity", 503, Some("NoCapacity"), Kind::NoCapacity),
186            ("503-start-timeout", 503, Some("StartTimeout"), Kind::NoCapacity),
187            ("503-no-code", 503, None, Kind::NoCapacity),
188            ("503-sandbox-unavailable", 503, Some("SandboxUnavailable"), Kind::Plain),
189            ("503-store-unavailable", 503, Some("StoreUnavailable"), Kind::Plain),
190            ("503-admission-unavailable", 503, Some("AdmissionUnavailable"), Kind::Plain),
191            ("503-dependency-unavailable", 503, Some("DependencyUnavailable"), Kind::Plain),
192            ("507-insufficient-capacity", 507, Some("InsufficientCapacity"), Kind::Plain),
193        ];
194        for (id, status, code, want) in cases {
195            let body = GatewayErrorBody { code: code.map(str::to_string), ..Default::default() };
196            let e = map_gateway_error(*status, Some(body));
197            assert_eq!(&kind(&e), want, "errors/{id}");
198            assert_eq!(e.status(), Some(*status), "errors/{id}: the status the gateway sent is kept");
199            let kept = match &e {
200                SolariError::Auth { code, .. }
201                | SolariError::Plan { code, .. }
202                | SolariError::ConcurrencyLimit { code, .. }
203                | SolariError::NoCapacity { code, .. }
204                | SolariError::Gateway { code, .. } => code.clone(),
205                _ => unreachable!(),
206            };
207            assert_eq!(kept.as_deref(), *code, "errors/{id}: the gateway's code is kept");
208        }
209    }
210}