1use serde::Deserialize;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum SolariErrorCode {
16 FeatureRequiresPlan,
18 ConcurrencyLimitExceeded,
20 PlanLimitExceeded,
22 BrowserUnhealthy,
24 InvalidSessionId,
28 ProxyTierUnavailable,
31 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 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#[derive(Debug, thiserror::Error)]
85pub enum SolariError {
86 #[error("{message}")]
88 Api {
89 status: u16,
90 code: Option<SolariErrorCode>,
91 message: String,
92 },
93
94 #[error("{message}")]
96 Transport { message: String },
97
98 #[error("{message}")]
100 Protocol { message: String },
101
102 #[error("{message}")]
104 Config { message: String },
105
106 #[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 pub fn status(&self) -> Option<u16> {
126 match self {
127 SolariError::Api { status, .. } => Some(*status),
128 _ => None,
129 }
130 }
131
132 pub fn code(&self) -> Option<&SolariErrorCode> {
134 match self {
135 SolariError::Api { code, .. } => code.as_ref(),
136 _ => None,
137 }
138 }
139}
140
141#[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
150pub(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 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
231pub(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}