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)]
154pub struct Error {
155 pub error_code: ErrorCode,
156 #[serde(rename = "error")]
157 pub detail: String,
158 pub retryable: bool,
159}
160
161impl Error {
162 #[must_use]
163 pub fn new(error_code: ErrorCode, detail: impl Into<String>) -> Self {
164 Self {
165 error_code,
166 detail: detail.into(),
167 retryable: error_code.retryable_default(),
168 }
169 }
170
171 #[must_use]
172 pub fn with_retryable(mut self, retryable: bool) -> Self {
173 self.retryable = retryable;
174 self
175 }
176}
177
178impl std::fmt::Display for Error {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 write!(f, "{}: {}", self.error_code, self.detail)
181 }
182}
183
184impl std::error::Error for Error {}
185
186impl From<std::io::Error> for Error {
187 fn from(err: std::io::Error) -> Self {
188 Self::new(ErrorCode::IoError, err.to_string())
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn every_variant_serializes_to_snake_case() {
198 let cases = [
199 (ErrorCode::NavigationTimeout, "navigation_timeout"),
200 (ErrorCode::RenderUnavailable, "render_unavailable"),
201 (ErrorCode::HostUnreachable, "host_unreachable"),
202 (ErrorCode::DnsResolutionFailed, "dns_resolution_failed"),
203 (ErrorCode::TargetUnreachable, "target_unreachable"),
204 (ErrorCode::TlsError, "tls_error"),
205 (ErrorCode::TabCrashed, "tab_crashed"),
206 (ErrorCode::ProfileLocked, "profile_locked"),
207 (ErrorCode::BrowserLaunchFailed, "browser_launch_failed"),
208 (ErrorCode::CdpUnavailable, "cdp_unavailable"),
209 (ErrorCode::CdpError, "cdp_error"),
210 (ErrorCode::CdpTimeout, "cdp_timeout"),
211 (ErrorCode::WaitSelectorUnmatched, "wait_selector_unmatched"),
212 (ErrorCode::BackendUnsupported, "backend_unsupported"),
213 (ErrorCode::ArtifactCaptureFailed, "artifact_capture_failed"),
214 (
215 ErrorCode::ArtifactCaptureTimeout,
216 "artifact_capture_timeout",
217 ),
218 (ErrorCode::ArtifactEmpty, "artifact_empty"),
219 (ErrorCode::ArtifactTiny, "artifact_tiny"),
220 (ErrorCode::BotWallDetected, "bot_wall_detected"),
221 (
222 ErrorCode::SecurityChallengeDetected,
223 "security_challenge_detected",
224 ),
225 (ErrorCode::NetworkNotIdle, "network_not_idle"),
226 (ErrorCode::NavigationStale, "navigation_stale"),
227 (ErrorCode::ObservationEmpty, "observation_empty"),
228 (ErrorCode::PendingXhrAtCapture, "pending_xhr_at_capture"),
229 (ErrorCode::ReadinessTimeout, "readiness_timeout"),
230 (ErrorCode::NetworkBodyTruncated, "network_body_truncated"),
231 (ErrorCode::ProfileNotFound, "profile_not_found"),
232 (ErrorCode::ProfileDeleteLocked, "profile_delete_locked"),
233 (ErrorCode::ProfileInvalidName, "profile_invalid_name"),
234 (
235 ErrorCode::ProfileRootUnavailable,
236 "profile_root_unavailable",
237 ),
238 (ErrorCode::InvalidArgument, "invalid_argument"),
239 (ErrorCode::InvalidEndpoint, "invalid_endpoint"),
240 (ErrorCode::IoError, "io_error"),
241 (ErrorCode::InternalError, "internal_error"),
242 ];
243 for (code, expected) in cases {
244 assert_eq!(code.as_str(), expected);
245 let json = serde_json::to_string(&code).unwrap_or_default();
246 assert_eq!(json, format!("\"{expected}\""), "{code:?}");
247 }
248 }
249
250 #[test]
251 fn retryable_defaults_match_spec_table() {
252 assert!(ErrorCode::NavigationTimeout.retryable_default());
253 assert!(ErrorCode::HostUnreachable.retryable_default());
254 assert!(ErrorCode::DnsResolutionFailed.retryable_default());
255 assert!(ErrorCode::TargetUnreachable.retryable_default());
256 assert!(ErrorCode::TabCrashed.retryable_default());
257 assert!(ErrorCode::CdpTimeout.retryable_default());
258
259 assert!(!ErrorCode::TlsError.retryable_default());
260 assert!(!ErrorCode::RenderUnavailable.retryable_default());
261 assert!(!ErrorCode::ProfileLocked.retryable_default());
262 assert!(!ErrorCode::CdpUnavailable.retryable_default());
263 assert!(!ErrorCode::BackendUnsupported.retryable_default());
264 assert!(!ErrorCode::WaitSelectorUnmatched.retryable_default());
265 assert!(!ErrorCode::ProfileNotFound.retryable_default());
266 assert!(!ErrorCode::ProfileDeleteLocked.retryable_default());
267 assert!(!ErrorCode::ProfileInvalidName.retryable_default());
268 }
269
270 #[test]
271 fn with_retryable_overrides_default() {
272 let err = Error::new(ErrorCode::CdpError, "boom").with_retryable(true);
273 assert!(err.retryable);
274 assert_eq!(err.error_code, ErrorCode::CdpError);
275 }
276
277 #[test]
278 fn error_serializes_with_renamed_detail_field() {
279 let err = Error::new(ErrorCode::NavigationTimeout, "page never loaded");
280 let json = serde_json::to_value(&err).unwrap_or(serde_json::Value::Null);
281 assert_eq!(json["error_code"], "navigation_timeout");
282 assert_eq!(json["error"], "page never loaded");
283 assert_eq!(json["retryable"], true);
284 }
285
286 #[test]
287 fn io_error_maps_to_io_error_code() {
288 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "nope");
289 let err: Error = io_err.into();
290 assert_eq!(err.error_code, ErrorCode::IoError);
291 assert!(err.retryable);
292 }
293}