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    /// The gateway refused a session id (malformed, forged, or another org's)
25    /// and acted on nothing. Only meaningful on a 404 — see [`crate::Sessions::release`]
26    /// for why that is not blanket-success.
27    InvalidSessionId,
28    /// A code this crate does not know about.
29    Other(String),
30}
31
32impl SolariErrorCode {
33    pub const FEATURE_REQUIRES_PLAN: &'static str = "FeatureRequiresPlan";
34    pub const CONCURRENCY_LIMIT_EXCEEDED: &'static str = "ConcurrencyLimitExceeded";
35    pub const PLAN_LIMIT_EXCEEDED: &'static str = "PlanLimitExceeded";
36    pub const BROWSER_UNHEALTHY: &'static str = "BrowserUnhealthy";
37    pub const INVALID_SESSION_ID: &'static str = "InvalidSessionId";
38
39    /// The wire string for this code.
40    pub fn as_str(&self) -> &str {
41        match self {
42            SolariErrorCode::FeatureRequiresPlan => Self::FEATURE_REQUIRES_PLAN,
43            SolariErrorCode::ConcurrencyLimitExceeded => Self::CONCURRENCY_LIMIT_EXCEEDED,
44            SolariErrorCode::PlanLimitExceeded => Self::PLAN_LIMIT_EXCEEDED,
45            SolariErrorCode::BrowserUnhealthy => Self::BROWSER_UNHEALTHY,
46            SolariErrorCode::InvalidSessionId => Self::INVALID_SESSION_ID,
47            SolariErrorCode::Other(s) => s,
48        }
49    }
50}
51
52impl From<&str> for SolariErrorCode {
53    fn from(s: &str) -> Self {
54        match s {
55            Self::FEATURE_REQUIRES_PLAN => SolariErrorCode::FeatureRequiresPlan,
56            Self::CONCURRENCY_LIMIT_EXCEEDED => SolariErrorCode::ConcurrencyLimitExceeded,
57            Self::PLAN_LIMIT_EXCEEDED => SolariErrorCode::PlanLimitExceeded,
58            Self::BROWSER_UNHEALTHY => SolariErrorCode::BrowserUnhealthy,
59            Self::INVALID_SESSION_ID => SolariErrorCode::InvalidSessionId,
60            other => SolariErrorCode::Other(other.to_string()),
61        }
62    }
63}
64
65impl From<String> for SolariErrorCode {
66    fn from(s: String) -> Self {
67        SolariErrorCode::from(s.as_str())
68    }
69}
70
71impl std::fmt::Display for SolariErrorCode {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.write_str(self.as_str())
74    }
75}
76
77/// Every error raised by this crate.
78#[derive(Debug, thiserror::Error)]
79pub enum SolariError {
80    /// A non-2xx response from the Solari API.
81    #[error("{message}")]
82    Api {
83        status: u16,
84        code: Option<SolariErrorCode>,
85        message: String,
86    },
87
88    /// A transport failure, or retries exhausted without a usable response.
89    #[error("{message}")]
90    Transport { message: String },
91
92    /// A 2xx response whose body was missing required fields or was not JSON.
93    #[error("{message}")]
94    Protocol { message: String },
95
96    /// The client was constructed with invalid options.
97    #[error("{message}")]
98    Config { message: String },
99
100    /// Attaching to the browser over CDP failed (only with the `connect` feature).
101    #[error("{message}")]
102    Connect { message: String },
103}
104
105impl SolariError {
106    pub(crate) fn transport(msg: impl Into<String>) -> Self {
107        SolariError::Transport { message: msg.into() }
108    }
109
110    pub(crate) fn protocol(msg: impl Into<String>) -> Self {
111        SolariError::Protocol { message: msg.into() }
112    }
113
114    pub(crate) fn config(msg: impl Into<String>) -> Self {
115        SolariError::Config { message: msg.into() }
116    }
117
118    /// The HTTP status this error carries, when it came from the API.
119    pub fn status(&self) -> Option<u16> {
120        match self {
121            SolariError::Api { status, .. } => Some(*status),
122            _ => None,
123        }
124    }
125
126    /// The machine-readable code parsed from the error body, when present.
127    pub fn code(&self) -> Option<&SolariErrorCode> {
128        match self {
129            SolariError::Api { code, .. } => code.as_ref(),
130            _ => None,
131        }
132    }
133}
134
135/// Shape the API's error bodies may take. Every field is optional — an error
136/// body is best-effort and may not be JSON at all.
137#[derive(Debug, Clone, Default, Deserialize)]
138pub struct ApiErrorBody {
139    pub code: Option<String>,
140    pub error: Option<String>,
141    pub message: Option<String>,
142}
143
144/// Build an [`SolariError::Api`] from a failed response, parsing `{code}` out of
145/// the body when it is JSON. `message` mirrors the TS format:
146/// `Solari <METHOD> <path> failed: <status> <body>`.
147pub(crate) fn api_error(method: &str, path: &str, status: u16, body: &str) -> SolariError {
148    let parsed: Option<ApiErrorBody> = serde_json::from_str(body).ok();
149    let code = parsed
150        .as_ref()
151        .and_then(|b| b.code.as_deref())
152        .map(SolariErrorCode::from);
153    SolariError::Api {
154        status,
155        code,
156        message: format!("Solari {method} {path} failed: {status} {body}"),
157    }
158}