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    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    /// Default retryability per `architecture.md §11`. Some codes (`cdp_error`,
55    /// `artifact_capture_failed`, `browser_launch_failed`) are "depends on
56    /// detail"; we report a conservative default and let callers override via
57    /// [`Error::with_retryable`] when they know better.
58    #[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            // "Depends" cases — conservative default is false; callers tune.
93            Self::BrowserLaunchFailed
94            | Self::CdpError
95            | Self::ArtifactCaptureFailed
96            | Self::ProfileRootUnavailable
97            | Self::InternalError => false,
98        }
99    }
100
101    /// Stable snake_case string. Mirrors serde output; useful when emitting
102    /// trace logs from the protocol writer without re-serializing.
103    #[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/// The structured error every `afhttp` failure path produces.
151///
152/// CLI output converts this error into an AFDATA protocol-v1 `error` event.
153#[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}