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