Skip to main content

agent_first_http/shared/
error.rs

1//! Structured error contract for `afhttp`.
2//!
3//! Every failure surface — fetch, host, profile, CDP — flows through this
4//! enum. The `ErrorCode` enum maps 1:1 to `architecture.md §11`; serialization
5//! is stable across versions and is part of the public contract.
6
7use serde::{Deserialize, Serialize};
8
9/// Stable error-code enum from `architecture.md §11`.
10///
11/// Variants serialize to their snake_case form (e.g. `ErrorCode::NavigationTimeout`
12/// → `"navigation_timeout"`). Agents match on this; they do **not** parse
13/// `Error::detail`.
14#[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    /// Default retryability per `architecture.md §11`. Some codes (`cdp_error`,
45    /// `artifact_capture_failed`, `browser_launch_failed`) are "depends on
46    /// detail"; we report a conservative default and let callers override via
47    /// [`Error::with_retryable`] when they know better.
48    #[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            // "Depends" cases — conservative default is false; callers tune.
73            Self::BrowserLaunchFailed
74            | Self::CdpError
75            | Self::ArtifactCaptureFailed
76            | Self::ProfileRootUnavailable
77            | Self::InternalError => false,
78        }
79    }
80
81    /// Stable snake_case string. Mirrors serde output; useful when emitting
82    /// trace logs from the protocol writer without re-serializing.
83    #[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/// The structured error every `afhttp` failure path produces.
121///
122/// Serializes as `{"code":"error","error_code":"<snake>","error":"...","retryable":bool}`
123/// when wrapped by the protocol envelope; the envelope adds the outer `code`
124/// discriminator. `Error` itself only carries the inner fields.
125#[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}