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 NetworkBodyTruncated,
33 ProfileNotFound,
34 ProfileDeleteLocked,
35 ProfileInvalidName,
36 ProfileRootUnavailable,
37 InvalidArgument,
38 InvalidEndpoint,
39 IoError,
40 InternalError,
41}
42
43impl ErrorCode {
44 #[must_use]
49 pub const fn retryable_default(self) -> bool {
50 match self {
51 Self::NavigationTimeout
52 | Self::HostUnreachable
53 | Self::DnsResolutionFailed
54 | Self::TargetUnreachable
55 | Self::TabCrashed
56 | Self::CdpTimeout
57 | Self::IoError => true,
58
59 Self::RenderUnavailable
60 | Self::TlsError
61 | Self::ProfileLocked
62 | Self::CdpUnavailable
63 | Self::WaitSelectorUnmatched
64 | Self::BackendUnsupported
65 | Self::NetworkBodyTruncated
66 | Self::ProfileNotFound
67 | Self::ProfileDeleteLocked
68 | Self::ProfileInvalidName
69 | Self::InvalidArgument
70 | Self::InvalidEndpoint => false,
71
72 Self::BrowserLaunchFailed
74 | Self::CdpError
75 | Self::ArtifactCaptureFailed
76 | Self::ProfileRootUnavailable
77 | Self::InternalError => false,
78 }
79 }
80
81 #[must_use]
84 pub const fn as_str(self) -> &'static str {
85 match self {
86 Self::NavigationTimeout => "navigation_timeout",
87 Self::RenderUnavailable => "render_unavailable",
88 Self::HostUnreachable => "host_unreachable",
89 Self::DnsResolutionFailed => "dns_resolution_failed",
90 Self::TargetUnreachable => "target_unreachable",
91 Self::TlsError => "tls_error",
92 Self::TabCrashed => "tab_crashed",
93 Self::ProfileLocked => "profile_locked",
94 Self::BrowserLaunchFailed => "browser_launch_failed",
95 Self::CdpUnavailable => "cdp_unavailable",
96 Self::CdpError => "cdp_error",
97 Self::CdpTimeout => "cdp_timeout",
98 Self::WaitSelectorUnmatched => "wait_selector_unmatched",
99 Self::BackendUnsupported => "backend_unsupported",
100 Self::ArtifactCaptureFailed => "artifact_capture_failed",
101 Self::NetworkBodyTruncated => "network_body_truncated",
102 Self::ProfileNotFound => "profile_not_found",
103 Self::ProfileDeleteLocked => "profile_delete_locked",
104 Self::ProfileInvalidName => "profile_invalid_name",
105 Self::ProfileRootUnavailable => "profile_root_unavailable",
106 Self::InvalidArgument => "invalid_argument",
107 Self::InvalidEndpoint => "invalid_endpoint",
108 Self::IoError => "io_error",
109 Self::InternalError => "internal_error",
110 }
111 }
112}
113
114impl std::fmt::Display for ErrorCode {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 f.write_str(self.as_str())
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct Error {
127 pub error_code: ErrorCode,
128 #[serde(rename = "error")]
129 pub detail: String,
130 pub retryable: bool,
131}
132
133impl Error {
134 #[must_use]
135 pub fn new(error_code: ErrorCode, detail: impl Into<String>) -> Self {
136 Self {
137 error_code,
138 detail: detail.into(),
139 retryable: error_code.retryable_default(),
140 }
141 }
142
143 #[must_use]
144 pub fn with_retryable(mut self, retryable: bool) -> Self {
145 self.retryable = retryable;
146 self
147 }
148}
149
150impl std::fmt::Display for Error {
151 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152 write!(f, "{}: {}", self.error_code, self.detail)
153 }
154}
155
156impl std::error::Error for Error {}
157
158impl From<std::io::Error> for Error {
159 fn from(err: std::io::Error) -> Self {
160 Self::new(ErrorCode::IoError, err.to_string())
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn every_variant_serializes_to_snake_case() {
170 let cases = [
171 (ErrorCode::NavigationTimeout, "navigation_timeout"),
172 (ErrorCode::RenderUnavailable, "render_unavailable"),
173 (ErrorCode::HostUnreachable, "host_unreachable"),
174 (ErrorCode::DnsResolutionFailed, "dns_resolution_failed"),
175 (ErrorCode::TargetUnreachable, "target_unreachable"),
176 (ErrorCode::TlsError, "tls_error"),
177 (ErrorCode::TabCrashed, "tab_crashed"),
178 (ErrorCode::ProfileLocked, "profile_locked"),
179 (ErrorCode::BrowserLaunchFailed, "browser_launch_failed"),
180 (ErrorCode::CdpUnavailable, "cdp_unavailable"),
181 (ErrorCode::CdpError, "cdp_error"),
182 (ErrorCode::CdpTimeout, "cdp_timeout"),
183 (ErrorCode::WaitSelectorUnmatched, "wait_selector_unmatched"),
184 (ErrorCode::BackendUnsupported, "backend_unsupported"),
185 (ErrorCode::ArtifactCaptureFailed, "artifact_capture_failed"),
186 (ErrorCode::NetworkBodyTruncated, "network_body_truncated"),
187 (ErrorCode::ProfileNotFound, "profile_not_found"),
188 (ErrorCode::ProfileDeleteLocked, "profile_delete_locked"),
189 (ErrorCode::ProfileInvalidName, "profile_invalid_name"),
190 (
191 ErrorCode::ProfileRootUnavailable,
192 "profile_root_unavailable",
193 ),
194 (ErrorCode::InvalidArgument, "invalid_argument"),
195 (ErrorCode::InvalidEndpoint, "invalid_endpoint"),
196 (ErrorCode::IoError, "io_error"),
197 (ErrorCode::InternalError, "internal_error"),
198 ];
199 for (code, expected) in cases {
200 assert_eq!(code.as_str(), expected);
201 let json = serde_json::to_string(&code).unwrap_or_default();
202 assert_eq!(json, format!("\"{expected}\""), "{code:?}");
203 }
204 }
205
206 #[test]
207 fn retryable_defaults_match_spec_table() {
208 assert!(ErrorCode::NavigationTimeout.retryable_default());
209 assert!(ErrorCode::HostUnreachable.retryable_default());
210 assert!(ErrorCode::DnsResolutionFailed.retryable_default());
211 assert!(ErrorCode::TargetUnreachable.retryable_default());
212 assert!(ErrorCode::TabCrashed.retryable_default());
213 assert!(ErrorCode::CdpTimeout.retryable_default());
214
215 assert!(!ErrorCode::TlsError.retryable_default());
216 assert!(!ErrorCode::RenderUnavailable.retryable_default());
217 assert!(!ErrorCode::ProfileLocked.retryable_default());
218 assert!(!ErrorCode::CdpUnavailable.retryable_default());
219 assert!(!ErrorCode::BackendUnsupported.retryable_default());
220 assert!(!ErrorCode::WaitSelectorUnmatched.retryable_default());
221 assert!(!ErrorCode::ProfileNotFound.retryable_default());
222 assert!(!ErrorCode::ProfileDeleteLocked.retryable_default());
223 assert!(!ErrorCode::ProfileInvalidName.retryable_default());
224 }
225
226 #[test]
227 fn with_retryable_overrides_default() {
228 let err = Error::new(ErrorCode::CdpError, "boom").with_retryable(true);
229 assert!(err.retryable);
230 assert_eq!(err.error_code, ErrorCode::CdpError);
231 }
232
233 #[test]
234 fn error_serializes_with_renamed_detail_field() {
235 let err = Error::new(ErrorCode::NavigationTimeout, "page never loaded");
236 let json = serde_json::to_value(&err).unwrap_or(serde_json::Value::Null);
237 assert_eq!(json["error_code"], "navigation_timeout");
238 assert_eq!(json["error"], "page never loaded");
239 assert_eq!(json["retryable"], true);
240 }
241
242 #[test]
243 fn io_error_maps_to_io_error_code() {
244 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "nope");
245 let err: Error = io_err.into();
246 assert_eq!(err.error_code, ErrorCode::IoError);
247 assert!(err.retryable);
248 }
249}