Skip to main content

rss_cli/
error.rs

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