1use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ErrorCode {
17 NavigationTimeout,
18 RenderUnavailable,
19 HostUnreachable,
20 DnsResolutionFailed,
21 TargetUnreachable,
22 TlsError,
23 TabCrashed,
24 ProfileLocked,
25 BrowserLaunchFailed,
26 CdpUnavailable,
27 CdpError,
28 CdpTimeout,
29 WaitSelectorUnmatched,
30 BackendUnsupported,
31 ArtifactCaptureFailed,
32 ArtifactCaptureTimeout,
33 ArtifactEmpty,
34 ArtifactTiny,
35 BotWallDetected,
36 SecurityChallengeDetected,
37 NetworkNotIdle,
38 NavigationStale,
39 ObservationEmpty,
40 PendingXhrAtCapture,
41 ReadinessTimeout,
42 NetworkBodyTruncated,
43 ProfileNotFound,
44 ProfileDeleteLocked,
45 ProfileInvalidName,
46 ProfileRootUnavailable,
47 InvalidArgument,
48 InvalidEndpoint,
49 IoError,
50 InternalError,
51}
52
53impl ErrorCode {
54 #[must_use]
59 pub const fn retryable_default(self) -> bool {
60 match self {
61 Self::NavigationTimeout
62 | Self::HostUnreachable
63 | Self::DnsResolutionFailed
64 | Self::TargetUnreachable
65 | Self::TabCrashed
66 | Self::CdpTimeout
67 | Self::IoError => true,
68
69 Self::RenderUnavailable
70 | Self::TlsError
71 | Self::ProfileLocked
72 | Self::CdpUnavailable
73 | Self::WaitSelectorUnmatched
74 | Self::BackendUnsupported
75 | Self::ArtifactCaptureTimeout
76 | Self::ArtifactEmpty
77 | Self::ArtifactTiny
78 | Self::BotWallDetected
79 | Self::SecurityChallengeDetected
80 | Self::NetworkNotIdle
81 | Self::NavigationStale
82 | Self::ObservationEmpty
83 | Self::PendingXhrAtCapture
84 | Self::ReadinessTimeout
85 | Self::NetworkBodyTruncated
86 | Self::ProfileNotFound
87 | Self::ProfileDeleteLocked
88 | Self::ProfileInvalidName
89 | Self::InvalidArgument
90 | Self::InvalidEndpoint => false,
91
92 Self::BrowserLaunchFailed
94 | Self::CdpError
95 | Self::ArtifactCaptureFailed
96 | Self::ProfileRootUnavailable
97 | Self::InternalError => false,
98 }
99 }
100
101 #[must_use]
104 pub const fn as_str(self) -> &'static str {
105 match self {
106 Self::NavigationTimeout => "navigation_timeout",
107 Self::RenderUnavailable => "render_unavailable",
108 Self::HostUnreachable => "host_unreachable",
109 Self::DnsResolutionFailed => "dns_resolution_failed",
110 Self::TargetUnreachable => "target_unreachable",
111 Self::TlsError => "tls_error",
112 Self::TabCrashed => "tab_crashed",
113 Self::ProfileLocked => "profile_locked",
114 Self::BrowserLaunchFailed => "browser_launch_failed",
115 Self::CdpUnavailable => "cdp_unavailable",
116 Self::CdpError => "cdp_error",
117 Self::CdpTimeout => "cdp_timeout",
118 Self::WaitSelectorUnmatched => "wait_selector_unmatched",
119 Self::BackendUnsupported => "backend_unsupported",
120 Self::ArtifactCaptureFailed => "artifact_capture_failed",
121 Self::ArtifactCaptureTimeout => "artifact_capture_timeout",
122 Self::ArtifactEmpty => "artifact_empty",
123 Self::ArtifactTiny => "artifact_tiny",
124 Self::BotWallDetected => "bot_wall_detected",
125 Self::SecurityChallengeDetected => "security_challenge_detected",
126 Self::NetworkNotIdle => "network_not_idle",
127 Self::NavigationStale => "navigation_stale",
128 Self::ObservationEmpty => "observation_empty",
129 Self::PendingXhrAtCapture => "pending_xhr_at_capture",
130 Self::ReadinessTimeout => "readiness_timeout",
131 Self::NetworkBodyTruncated => "network_body_truncated",
132 Self::ProfileNotFound => "profile_not_found",
133 Self::ProfileDeleteLocked => "profile_delete_locked",
134 Self::ProfileInvalidName => "profile_invalid_name",
135 Self::ProfileRootUnavailable => "profile_root_unavailable",
136 Self::InvalidArgument => "invalid_argument",
137 Self::InvalidEndpoint => "invalid_endpoint",
138 Self::IoError => "io_error",
139 Self::InternalError => "internal_error",
140 }
141 }
142}
143
144impl std::fmt::Display for ErrorCode {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 f.write_str(self.as_str())
147 }
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct Error {
157 pub error_code: ErrorCode,
158 #[serde(rename = "error")]
159 pub detail: String,
160 pub retryable: bool,
161}
162
163impl Error {
164 #[must_use]
165 pub fn new(error_code: ErrorCode, detail: impl Into<String>) -> Self {
166 Self {
167 error_code,
168 detail: detail.into(),
169 retryable: error_code.retryable_default(),
170 }
171 }
172
173 #[must_use]
174 pub fn with_retryable(mut self, retryable: bool) -> Self {
175 self.retryable = retryable;
176 self
177 }
178}
179
180impl std::fmt::Display for Error {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 write!(f, "{}: {}", self.error_code, self.detail)
183 }
184}
185
186impl std::error::Error for Error {}
187
188impl From<std::io::Error> for Error {
189 fn from(err: std::io::Error) -> Self {
190 Self::new(ErrorCode::IoError, err.to_string())
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn every_variant_serializes_to_snake_case() {
200 let cases = [
201 (ErrorCode::NavigationTimeout, "navigation_timeout"),
202 (ErrorCode::RenderUnavailable, "render_unavailable"),
203 (ErrorCode::HostUnreachable, "host_unreachable"),
204 (ErrorCode::DnsResolutionFailed, "dns_resolution_failed"),
205 (ErrorCode::TargetUnreachable, "target_unreachable"),
206 (ErrorCode::TlsError, "tls_error"),
207 (ErrorCode::TabCrashed, "tab_crashed"),
208 (ErrorCode::ProfileLocked, "profile_locked"),
209 (ErrorCode::BrowserLaunchFailed, "browser_launch_failed"),
210 (ErrorCode::CdpUnavailable, "cdp_unavailable"),
211 (ErrorCode::CdpError, "cdp_error"),
212 (ErrorCode::CdpTimeout, "cdp_timeout"),
213 (ErrorCode::WaitSelectorUnmatched, "wait_selector_unmatched"),
214 (ErrorCode::BackendUnsupported, "backend_unsupported"),
215 (ErrorCode::ArtifactCaptureFailed, "artifact_capture_failed"),
216 (
217 ErrorCode::ArtifactCaptureTimeout,
218 "artifact_capture_timeout",
219 ),
220 (ErrorCode::ArtifactEmpty, "artifact_empty"),
221 (ErrorCode::ArtifactTiny, "artifact_tiny"),
222 (ErrorCode::BotWallDetected, "bot_wall_detected"),
223 (
224 ErrorCode::SecurityChallengeDetected,
225 "security_challenge_detected",
226 ),
227 (ErrorCode::NetworkNotIdle, "network_not_idle"),
228 (ErrorCode::NavigationStale, "navigation_stale"),
229 (ErrorCode::ObservationEmpty, "observation_empty"),
230 (ErrorCode::PendingXhrAtCapture, "pending_xhr_at_capture"),
231 (ErrorCode::ReadinessTimeout, "readiness_timeout"),
232 (ErrorCode::NetworkBodyTruncated, "network_body_truncated"),
233 (ErrorCode::ProfileNotFound, "profile_not_found"),
234 (ErrorCode::ProfileDeleteLocked, "profile_delete_locked"),
235 (ErrorCode::ProfileInvalidName, "profile_invalid_name"),
236 (
237 ErrorCode::ProfileRootUnavailable,
238 "profile_root_unavailable",
239 ),
240 (ErrorCode::InvalidArgument, "invalid_argument"),
241 (ErrorCode::InvalidEndpoint, "invalid_endpoint"),
242 (ErrorCode::IoError, "io_error"),
243 (ErrorCode::InternalError, "internal_error"),
244 ];
245 for (code, expected) in cases {
246 assert_eq!(code.as_str(), expected);
247 let json = serde_json::to_string(&code).unwrap_or_default();
248 assert_eq!(json, format!("\"{expected}\""), "{code:?}");
249 }
250 }
251
252 #[test]
253 fn retryable_defaults_match_spec_table() {
254 assert!(ErrorCode::NavigationTimeout.retryable_default());
255 assert!(ErrorCode::HostUnreachable.retryable_default());
256 assert!(ErrorCode::DnsResolutionFailed.retryable_default());
257 assert!(ErrorCode::TargetUnreachable.retryable_default());
258 assert!(ErrorCode::TabCrashed.retryable_default());
259 assert!(ErrorCode::CdpTimeout.retryable_default());
260
261 assert!(!ErrorCode::TlsError.retryable_default());
262 assert!(!ErrorCode::RenderUnavailable.retryable_default());
263 assert!(!ErrorCode::ProfileLocked.retryable_default());
264 assert!(!ErrorCode::CdpUnavailable.retryable_default());
265 assert!(!ErrorCode::BackendUnsupported.retryable_default());
266 assert!(!ErrorCode::WaitSelectorUnmatched.retryable_default());
267 assert!(!ErrorCode::ProfileNotFound.retryable_default());
268 assert!(!ErrorCode::ProfileDeleteLocked.retryable_default());
269 assert!(!ErrorCode::ProfileInvalidName.retryable_default());
270 }
271
272 #[test]
273 fn with_retryable_overrides_default() {
274 let err = Error::new(ErrorCode::CdpError, "boom").with_retryable(true);
275 assert!(err.retryable);
276 assert_eq!(err.error_code, ErrorCode::CdpError);
277 }
278
279 #[test]
280 fn error_serializes_with_renamed_detail_field() {
281 let err = Error::new(ErrorCode::NavigationTimeout, "page never loaded");
282 let json = serde_json::to_value(&err).unwrap_or(serde_json::Value::Null);
283 assert_eq!(json["error_code"], "navigation_timeout");
284 assert_eq!(json["error"], "page never loaded");
285 assert_eq!(json["retryable"], true);
286 }
287
288 #[test]
289 fn io_error_maps_to_io_error_code() {
290 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "nope");
291 let err: Error = io_err.into();
292 assert_eq!(err.error_code, ErrorCode::IoError);
293 assert!(err.retryable);
294 }
295}