Skip to main content

rss_cli/
error.rs

1//! Error type, stable error codes, and process exit codes.
2
3use std::time::Duration;
4
5use crate::model::ErrorObj;
6
7/// Process exit codes (stable contract for scripts and agents).
8pub mod exit {
9    /// All feeds succeeded.
10    pub const OK: i32 = 0;
11    /// Unexpected internal error.
12    pub const UNEXPECTED: i32 = 1;
13    /// Usage / argument error.
14    pub const USAGE: i32 = 2;
15    /// Some feeds succeeded, some failed.
16    pub const PARTIAL: i32 = 3;
17    /// Every requested feed failed.
18    pub const ALL_FAILED: i32 = 4;
19}
20
21/// Library error type. Carries a stable [`code`](RssError::code) used in [`ErrorObj`].
22#[derive(Debug, thiserror::Error)]
23pub enum RssError {
24    #[error("usage error: {0}")]
25    Usage(String),
26
27    #[error("invalid URL: {0}")]
28    InvalidUrl(String),
29
30    #[error("network error: {0}")]
31    Network(String),
32
33    #[error("HTTP status {status}: {url}")]
34    Http {
35        status: u16,
36        url: String,
37        /// Raw `Retry-After` header value when the server sent one (delta-seconds form).
38        retry_after: Option<String>,
39    },
40
41    #[error("rate limited: {url} (retry after ~{}s)", retry_after.as_secs())]
42    RateLimited {
43        url: String,
44        /// How long the caller should wait before retrying, per the per-host gate (ADR-0016).
45        retry_after: Duration,
46    },
47
48    #[error("feed parse error: {0}")]
49    Parse(String),
50
51    #[error("no feeds discovered at {0}")]
52    NotFound(String),
53
54    #[error("cache error: {0}")]
55    Cache(String),
56
57    #[error(
58        "response too large: ~{estimated_tokens} tokens exceeds the {budget_tokens}-token \
59         budget; retry with limit={suggested_limit} or max_content_chars={suggested_max_content_chars}"
60    )]
61    ResponseTooLarge {
62        estimated_tokens: usize,
63        budget_tokens: usize,
64        suggested_limit: usize,
65        suggested_max_content_chars: usize,
66    },
67
68    #[error(transparent)]
69    Io(#[from] std::io::Error),
70
71    #[error("{0}")]
72    Other(String),
73}
74
75impl RssError {
76    /// Stable, machine-readable error code (`SCREAMING_SNAKE_CASE`).
77    pub fn code(&self) -> &'static str {
78        match self {
79            RssError::Usage(_) => "USAGE_ERROR",
80            RssError::InvalidUrl(_) => "INVALID_URL",
81            RssError::Network(_) => "NETWORK_ERROR",
82            RssError::Http { .. } => "FEED_FETCH_FAILED",
83            RssError::RateLimited { .. } => "RATE_LIMITED",
84            RssError::Parse(_) => "FEED_PARSE_FAILED",
85            RssError::NotFound(_) => "NOT_FOUND",
86            RssError::Cache(_) => "CACHE_ERROR",
87            RssError::ResponseTooLarge { .. } => "RESPONSE_TOO_LARGE",
88            RssError::Io(_) => "IO_ERROR",
89            RssError::Other(_) => "INTERNAL_ERROR",
90        }
91    }
92
93    /// Convert into the serialized [`ErrorObj`], attaching any structured details.
94    pub fn to_error_obj(&self, feed_url: Option<&str>) -> ErrorObj {
95        let mut obj = ErrorObj::new(self.code(), self.to_string());
96        if let Some(u) = feed_url {
97            obj.feed_url = Some(u.to_string());
98        }
99        match self {
100            RssError::Http {
101                status,
102                retry_after,
103                ..
104            } => {
105                obj.details = serde_json::json!({
106                    "http_status": status,
107                    "retry_after": retry_after,
108                });
109            }
110            RssError::RateLimited { retry_after, .. } => {
111                // Machine-readable pacing hint so the agent can wait, not give up.
112                obj.details = serde_json::json!({
113                    "retry_after_seconds": retry_after.as_secs(),
114                    "retry_after_ms": u64::try_from(retry_after.as_millis()).unwrap_or(u64::MAX),
115                });
116            }
117            RssError::ResponseTooLarge {
118                estimated_tokens,
119                budget_tokens,
120                suggested_limit,
121                suggested_max_content_chars,
122            } => {
123                // Machine-readable remediation so the agent can retry without giving up.
124                obj.details = serde_json::json!({
125                    "estimated_tokens": estimated_tokens,
126                    "budget_tokens": budget_tokens,
127                    "suggested_limit": suggested_limit,
128                    "suggested_max_content_chars": suggested_max_content_chars,
129                });
130            }
131            _ => {}
132        }
133        obj
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn http_error_surfaces_retry_after_when_present() {
143        let e = RssError::Http {
144            status: 429,
145            url: "https://x".into(),
146            retry_after: Some("2".into()),
147        };
148        let obj = e.to_error_obj(None);
149        assert_eq!(obj.code, "FEED_FETCH_FAILED");
150        assert_eq!(obj.details["http_status"], 429);
151        assert_eq!(obj.details["retry_after"], "2");
152    }
153
154    #[test]
155    fn rate_limited_surfaces_code_and_retry_after_details() {
156        // Pins the AI-facing contract for the gate's fail-fast error (ADR-0016): stable code
157        // plus machine-readable pacing hints in the free-form details.
158        let e = RssError::RateLimited {
159            url: "https://x/feed".into(),
160            retry_after: Duration::from_secs(12),
161        };
162        let obj = e.to_error_obj(Some("https://x/feed"));
163        assert_eq!(obj.code, "RATE_LIMITED");
164        assert_eq!(obj.feed_url.as_deref(), Some("https://x/feed"));
165        assert_eq!(obj.details["retry_after_seconds"], 12);
166        assert_eq!(obj.details["retry_after_ms"], 12_000);
167    }
168}