1use std::time::Duration;
4
5use crate::model::ErrorObj;
6
7pub mod exit {
9 pub const OK: i32 = 0;
11 pub const UNEXPECTED: i32 = 1;
13 pub const USAGE: i32 = 2;
15 pub const PARTIAL: i32 = 3;
17 pub const ALL_FAILED: i32 = 4;
19}
20
21#[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 retry_after: Option<String>,
39 },
40
41 #[error("rate limited: {url} (retry after ~{}s)", retry_after.as_secs())]
42 RateLimited {
43 url: String,
44 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 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 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 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 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 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}