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/// `body` with the session id of `path` (`/sessions/<id>/…`), in any spelling, and every `"sessionId":"…"` field cut to their
151/// first 8 characters. The id is the credential inside the ws/cdp URLs and the gateway's error bodies used to echo it (the
152/// replay-url 404), so it must not reach an error message through the body either.
153pub(crate) fn redact_body(path: &str, body: &str) -> String {
154    fn short(id: &str) -> String {
155        let keep: String = id.chars().take(8).collect();
156        if id.chars().count() > 8 { format!("{keep}…") } else { keep }
157    }
158    let mut out = body.to_string();
159    let mut after_sessions = false;
160    for seg in path.split('/') {
161        if after_sessions && !seg.is_empty() {
162            let raw = seg.split(['?', '#']).next().unwrap_or(seg);
163            if raw.chars().count() > 8 {
164                let plain = percent_decode(raw);
165                for form in [raw.to_string(), percent_encode(&plain), plain.clone()] {
166                    out = out.replace(&form, &short(&plain));
167                }
168            }
169        }
170        after_sessions = seg == "sessions";
171    }
172    // "sessionId":"<value>" fields, whatever the id.
173    let key = "\"sessionId\"";
174    let mut res = String::with_capacity(out.len());
175    let mut rest = out.as_str();
176    while let Some(at) = rest.find(key) {
177        let (head, tail) = rest.split_at(at + key.len());
178        res.push_str(head);
179        let t = tail.trim_start();
180        let ws = tail.len() - t.len();
181        if let Some(t) = t.strip_prefix(':') {
182            let t2 = t.trim_start();
183            if let Some(v) = t2.strip_prefix('"') {
184                if let Some(end) = v.find('"') {
185                    res.push_str(&tail[..ws]);
186                    res.push(':');
187                    res.push_str(&t[..t.len() - t2.len()]);
188                    res.push('"');
189                    res.push_str(&short(&v[..end]));
190                    rest = &v[end..];
191                    continue;
192                }
193            }
194        }
195        rest = tail;
196    }
197    res.push_str(rest);
198    res
199}
200
201fn percent_decode(s: &str) -> String {
202    let b = s.as_bytes();
203    let mut out = Vec::with_capacity(b.len());
204    let mut i = 0;
205    while i < b.len() {
206        if b[i] == b'%' && i + 2 < b.len() {
207            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
208                out.push(v);
209                i += 3;
210                continue;
211            }
212        }
213        out.push(b[i]);
214        i += 1;
215    }
216    String::from_utf8_lossy(&out).into_owned()
217}
218
219fn percent_encode(s: &str) -> String {
220    let mut out = String::new();
221    for c in s.bytes() {
222        if c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.' | b'~') {
223            out.push(c as char);
224        } else {
225            out.push_str(&format!("%{c:02X}"));
226        }
227    }
228    out
229}
230
231/// Build an [`SolariError::Api`] from a failed response, parsing `{code}` out of
232/// the body when it is JSON. `message` mirrors the TS format:
233/// `Solari <METHOD> <path> failed: <status> <body>`.
234pub(crate) fn api_error(method: &str, path: &str, status: u16, body: &str) -> SolariError {
235    let parsed: Option<ApiErrorBody> = serde_json::from_str(body).ok();
236    let code = parsed
237        .as_ref()
238        .and_then(|b| b.code.as_deref())
239        .map(SolariErrorCode::from);
240    SolariError::Api {
241        status,
242        code,
243        message: format!(
244            "Solari {method} {} failed: {status} {}",
245            crate::http::redact_path(path),
246            redact_body(path, body)
247        ),
248    }
249}
250
251#[cfg(test)]
252mod redaction_tests {
253    use super::{api_error, redact_body};
254
255    #[test]
256    fn an_api_error_does_not_print_the_session_id() {
257        let e = api_error("GET", "/sessions/pool:abc:org:123.IDSECRET", 404, "{}").to_string();
258        assert!(!e.contains("IDSECRET"), "{e}");
259        assert!(e.contains("/sessions/pool:abc…"), "{e}");
260    }
261
262    const ID: &str = "pool-abc:org-123.IDSECRETSIGNATURE";
263
264    #[test]
265    fn a_body_that_echoes_the_session_id_is_cut() {
266        let path = format!("/sessions/{ID}/replay-url");
267        for body in [
268            format!(r#"{{"error":"Replay not ready yet","code":"ReplayPending","retryable":true,"sessionId":"{ID}"}}"#),
269            format!(r#"{{"error":"no session {ID} here"}}"#),
270            r#"{"error":"no session pool-abc%3Aorg-123.IDSECRETSIGNATURE here"}"#.to_string(),
271            format!(r#"{{ "sessionId" : "{ID}" }}"#),
272        ] {
273            let e = api_error("GET", &path, 404, &body);
274            let text = e.to_string();
275            assert!(!text.contains("IDSECRET"), "{text}");
276            assert_eq!(e.status(), Some(404));
277        }
278    }
279
280    #[test]
281    fn the_code_is_still_parsed_from_the_raw_body() {
282        let body = format!(r#"{{"code":"ReplayPending","sessionId":"{ID}"}}"#);
283        let e = api_error("GET", &format!("/sessions/{ID}/replay-url"), 404, &body);
284        assert_eq!(e.code().map(|c| c.to_string()), Some("ReplayPending".to_string()));
285    }
286
287    #[test]
288    fn a_session_id_field_is_cut_even_when_the_path_carries_no_id() {
289        let body = format!(r#"{{"error":"busy","code":"Conflict","sessionId":"{ID}"}}"#);
290        let e = api_error("GET", "/profiles", 409, &body);
291        let text = e.to_string();
292        assert!(!text.contains("IDSECRET"), "{text}");
293        assert!(text.contains(r#""sessionId":"pool-abc…""#), "{text}");
294        assert_eq!(e.code().map(|c| c.to_string()), Some("Conflict".to_string()));
295    }
296
297    #[test]
298    fn redact_body_leaves_short_ids_and_unrelated_text_alone() {
299        assert_eq!(redact_body("/sessions/short", r#"{"x":"short"}"#), r#"{"x":"short"}"#);
300        assert_eq!(redact_body("/profiles/abcdefghijkl", "abcdefghijkl"), "abcdefghijkl");
301    }
302}