Skip to main content

solari_browser/
error.rs

1//! Typed errors for the Solari Browser SDK.
2//!
3//! Mirrors the reference `SolariError` class in `sdk/src/index.ts`: one error
4//! type carrying an optional HTTP `status` and an optional machine-readable
5//! `code` parsed out of the API's `{ "code": … }` error body.
6
7use serde::Deserialize;
8
9/// Machine-readable error codes the API returns in a `{ "code": … }` body.
10///
11/// Mirrors the TypeScript `SolariErrorCode` union. Unknown codes are preserved
12/// verbatim in [`SolariErrorCode::Other`] rather than dropped — the wire is
13/// allowed to grow new codes without breaking this crate.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum SolariErrorCode {
16    /// The org's plan does not include the requested feature (e.g. captcha).
17    FeatureRequiresPlan,
18    /// The org is at its concurrent-session cap.
19    ConcurrencyLimitExceeded,
20    /// A plan quota (minutes, profiles, …) is exhausted.
21    PlanLimitExceeded,
22    /// The acquired browser failed its health check.
23    BrowserUnhealthy,
24    /// A code this crate does not know about.
25    Other(String),
26}
27
28impl SolariErrorCode {
29    pub const FEATURE_REQUIRES_PLAN: &'static str = "FeatureRequiresPlan";
30    pub const CONCURRENCY_LIMIT_EXCEEDED: &'static str = "ConcurrencyLimitExceeded";
31    pub const PLAN_LIMIT_EXCEEDED: &'static str = "PlanLimitExceeded";
32    pub const BROWSER_UNHEALTHY: &'static str = "BrowserUnhealthy";
33
34    /// The wire string for this code.
35    pub fn as_str(&self) -> &str {
36        match self {
37            SolariErrorCode::FeatureRequiresPlan => Self::FEATURE_REQUIRES_PLAN,
38            SolariErrorCode::ConcurrencyLimitExceeded => Self::CONCURRENCY_LIMIT_EXCEEDED,
39            SolariErrorCode::PlanLimitExceeded => Self::PLAN_LIMIT_EXCEEDED,
40            SolariErrorCode::BrowserUnhealthy => Self::BROWSER_UNHEALTHY,
41            SolariErrorCode::Other(s) => s,
42        }
43    }
44}
45
46impl From<&str> for SolariErrorCode {
47    fn from(s: &str) -> Self {
48        match s {
49            Self::FEATURE_REQUIRES_PLAN => SolariErrorCode::FeatureRequiresPlan,
50            Self::CONCURRENCY_LIMIT_EXCEEDED => SolariErrorCode::ConcurrencyLimitExceeded,
51            Self::PLAN_LIMIT_EXCEEDED => SolariErrorCode::PlanLimitExceeded,
52            Self::BROWSER_UNHEALTHY => SolariErrorCode::BrowserUnhealthy,
53            other => SolariErrorCode::Other(other.to_string()),
54        }
55    }
56}
57
58impl From<String> for SolariErrorCode {
59    fn from(s: String) -> Self {
60        SolariErrorCode::from(s.as_str())
61    }
62}
63
64impl std::fmt::Display for SolariErrorCode {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.write_str(self.as_str())
67    }
68}
69
70/// Every error raised by this crate.
71#[derive(Debug, thiserror::Error)]
72pub enum SolariError {
73    /// A non-2xx response from the Solari API.
74    #[error("{message}")]
75    Api {
76        status: u16,
77        code: Option<SolariErrorCode>,
78        message: String,
79    },
80
81    /// A transport failure, or retries exhausted without a usable response.
82    #[error("{message}")]
83    Transport { message: String },
84
85    /// A 2xx response whose body was missing required fields or was not JSON.
86    #[error("{message}")]
87    Protocol { message: String },
88
89    /// The client was constructed with invalid options.
90    #[error("{message}")]
91    Config { message: String },
92
93    /// Attaching to the browser over CDP failed (only with the `connect` feature).
94    #[error("{message}")]
95    Connect { message: String },
96}
97
98impl SolariError {
99    pub(crate) fn transport(msg: impl Into<String>) -> Self {
100        SolariError::Transport { message: msg.into() }
101    }
102
103    pub(crate) fn protocol(msg: impl Into<String>) -> Self {
104        SolariError::Protocol { message: msg.into() }
105    }
106
107    pub(crate) fn config(msg: impl Into<String>) -> Self {
108        SolariError::Config { message: msg.into() }
109    }
110
111    /// The HTTP status this error carries, when it came from the API.
112    pub fn status(&self) -> Option<u16> {
113        match self {
114            SolariError::Api { status, .. } => Some(*status),
115            _ => None,
116        }
117    }
118
119    /// The machine-readable code parsed from the error body, when present.
120    pub fn code(&self) -> Option<&SolariErrorCode> {
121        match self {
122            SolariError::Api { code, .. } => code.as_ref(),
123            _ => None,
124        }
125    }
126}
127
128/// Shape the API's error bodies may take. Every field is optional — an error
129/// body is best-effort and may not be JSON at all.
130#[derive(Debug, Clone, Default, Deserialize)]
131pub struct ApiErrorBody {
132    pub code: Option<String>,
133    pub error: Option<String>,
134    pub message: Option<String>,
135}
136
137/// Build an [`SolariError::Api`] from a failed response, parsing `{code}` out of
138/// the body when it is JSON. `message` mirrors the TS format:
139/// `Solari <METHOD> <path> failed: <status> <body>`.
140pub(crate) fn api_error(method: &str, path: &str, status: u16, body: &str) -> SolariError {
141    let parsed: Option<ApiErrorBody> = serde_json::from_str(body).ok();
142    let code = parsed
143        .as_ref()
144        .and_then(|b| b.code.as_deref())
145        .map(SolariErrorCode::from);
146    SolariError::Api {
147        status,
148        code,
149        message: format!("Solari {method} {path} failed: {status} {body}"),
150    }
151}