1use std::time::Duration;
25
26use chrono::{DateTime, Utc};
27use reqwest::StatusCode;
28use reqwest::header::{
29 CONTENT_TYPE, ETAG, HeaderMap, HeaderName, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED,
30};
31use tokio::time::sleep;
32
33use crate::cache::{Cache, CacheMeta};
34use crate::config::CachePolicy;
35use crate::error::RssError;
36
37#[derive(Debug, Clone)]
39pub struct RawFeed {
40 pub body: Vec<u8>,
41 pub final_url: String,
43 pub content_type: Option<String>,
44 pub status: u16,
45 pub not_modified: bool,
47 pub from_cache: bool,
49}
50
51#[derive(Clone)]
53pub struct HttpClient {
54 #[allow(dead_code)]
55 inner: reqwest::Client,
56}
57
58impl HttpClient {
59 pub fn new(user_agent: &str, timeout: Duration) -> Result<Self, RssError> {
61 let inner = reqwest::Client::builder()
62 .user_agent(user_agent)
63 .timeout(timeout)
64 .gzip(true)
65 .build()
66 .map_err(|e| RssError::Network(e.to_string()))?;
67 Ok(Self { inner })
68 }
69
70 pub async fn fetch(
72 &self,
73 url: &str,
74 cache: &Cache,
75 policy: CachePolicy,
76 ) -> Result<RawFeed, RssError> {
77 match policy {
78 CachePolicy::NoCache => {
80 let resp = send_with_retry(|| self.inner.get(url)).await?;
81 let status = resp.status();
82 let final_url = resp.url().to_string();
83 if !status.is_success() {
84 return Err(RssError::Http {
85 status: status.as_u16(),
86 url: url.to_string(),
87 retry_after: retry_after_raw(resp.headers()),
88 });
89 }
90 let content_type = header_string(resp.headers(), &CONTENT_TYPE);
91 let body = resp
92 .bytes()
93 .await
94 .map_err(|e| RssError::Network(e.to_string()))?
95 .to_vec();
96 Ok(RawFeed {
97 body,
98 final_url,
99 content_type,
100 status: status.as_u16(),
101 not_modified: false,
102 from_cache: false,
103 })
104 }
105
106 CachePolicy::MaxAge(max_age) => {
108 if let Some(entry) = cache.get(url)?
109 && is_fresh(&entry.meta.fetched_at, max_age)
110 {
111 return Ok(RawFeed {
112 body: entry.body,
113 final_url: url.to_string(),
114 content_type: entry.meta.content_type,
115 status: 200,
116 not_modified: true,
117 from_cache: true,
118 });
119 }
120 self.revalidate(url, cache).await
121 }
122
123 CachePolicy::CacheFirst => {
126 if let Some(entry) = cache.get(url)? {
127 return Ok(RawFeed {
128 body: entry.body,
129 final_url: url.to_string(),
130 content_type: entry.meta.content_type,
131 status: 200,
132 not_modified: true,
133 from_cache: true,
134 });
135 }
136 self.revalidate(url, cache).await
137 }
138
139 CachePolicy::Revalidate => self.revalidate(url, cache).await,
141 }
142 }
143
144 async fn revalidate(&self, url: &str, cache: &Cache) -> Result<RawFeed, RssError> {
147 let cached = cache.get(url)?;
148
149 let build = || {
150 let mut req = self.inner.get(url);
151 if let Some(entry) = &cached {
152 if let Some(etag) = &entry.meta.etag {
153 req = req.header(IF_NONE_MATCH, etag.as_str());
154 }
155 if let Some(last_modified) = &entry.meta.last_modified {
156 req = req.header(IF_MODIFIED_SINCE, last_modified.as_str());
157 }
158 }
159 req
160 };
161
162 let resp = send_with_retry(build).await?;
163 let status = resp.status();
164 let final_url = resp.url().to_string();
165
166 if status == StatusCode::NOT_MODIFIED {
168 let entry = cached.ok_or_else(|| {
169 RssError::Network(format!(
170 "server returned 304 but no cache entry exists for {url}"
171 ))
172 })?;
173 let meta = CacheMeta {
174 feed_url: url.to_string(),
175 etag: entry.meta.etag.clone(),
176 last_modified: entry.meta.last_modified.clone(),
177 fetched_at: now_rfc3339(),
178 content_type: entry.meta.content_type.clone(),
179 };
180 cache.put(&meta, &entry.body)?;
181 return Ok(RawFeed {
182 body: entry.body,
183 final_url,
184 content_type: entry.meta.content_type,
185 status: StatusCode::NOT_MODIFIED.as_u16(),
186 not_modified: true,
187 from_cache: true,
188 });
189 }
190
191 if !status.is_success() {
192 return Err(RssError::Http {
193 status: status.as_u16(),
194 url: url.to_string(),
195 retry_after: retry_after_raw(resp.headers()),
196 });
197 }
198
199 let content_type = header_string(resp.headers(), &CONTENT_TYPE);
201 let etag = header_string(resp.headers(), &ETAG);
202 let last_modified = header_string(resp.headers(), &LAST_MODIFIED);
203 let body = resp
204 .bytes()
205 .await
206 .map_err(|e| RssError::Network(e.to_string()))?
207 .to_vec();
208
209 let meta = CacheMeta {
210 feed_url: url.to_string(),
211 etag,
212 last_modified,
213 fetched_at: now_rfc3339(),
214 content_type: content_type.clone(),
215 };
216 cache.put(&meta, &body)?;
217
218 Ok(RawFeed {
219 body,
220 final_url,
221 content_type,
222 status: status.as_u16(),
223 not_modified: false,
224 from_cache: false,
225 })
226 }
227
228 pub async fn get_bytes(&self, url: &str) -> Result<(Vec<u8>, String), RssError> {
230 let resp = send_with_retry(|| self.inner.get(url)).await?;
231 let status = resp.status();
232 let final_url = resp.url().to_string();
233 if !status.is_success() {
234 return Err(RssError::Http {
235 status: status.as_u16(),
236 url: url.to_string(),
237 retry_after: retry_after_raw(resp.headers()),
238 });
239 }
240 let body = resp
241 .bytes()
242 .await
243 .map_err(|e| RssError::Network(e.to_string()))?
244 .to_vec();
245 Ok((body, final_url))
246 }
247}
248
249fn is_retryable(status: StatusCode) -> bool {
251 status == StatusCode::FORBIDDEN || status == StatusCode::TOO_MANY_REQUESTS
252}
253
254fn retry_after(headers: &HeaderMap, max: Duration) -> Option<Duration> {
257 let raw = headers.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?;
258 let secs: u64 = raw.trim().parse().ok()?;
259 Some(Duration::from_secs(secs).min(max))
260}
261
262fn retry_after_raw(headers: &HeaderMap) -> Option<String> {
264 headers
265 .get(reqwest::header::RETRY_AFTER)
266 .and_then(|v| v.to_str().ok())
267 .map(str::to_owned)
268}
269
270const RETRY_BASE_DELAY: Duration = Duration::from_millis(500);
271const RETRY_MAX_DELAY: Duration = Duration::from_secs(5);
272
273async fn send_with_retry<F>(build: F) -> Result<reqwest::Response, RssError>
276where
277 F: Fn() -> reqwest::RequestBuilder,
278{
279 let resp = build()
280 .send()
281 .await
282 .map_err(|e| RssError::Network(e.to_string()))?;
283 if !is_retryable(resp.status()) {
284 return Ok(resp);
285 }
286 let wait = retry_after(resp.headers(), RETRY_MAX_DELAY).unwrap_or(RETRY_BASE_DELAY);
287 sleep(wait).await;
288 build()
289 .send()
290 .await
291 .map_err(|e| RssError::Network(e.to_string()))
292}
293
294fn is_fresh(fetched_at: &str, max_age: Duration) -> bool {
296 let Ok(fetched) = DateTime::parse_from_rfc3339(fetched_at) else {
297 return false;
298 };
299 let Ok(max_age) = chrono::Duration::from_std(max_age) else {
300 return true;
302 };
303 Utc::now().signed_duration_since(fetched) < max_age
304}
305
306fn now_rfc3339() -> String {
308 Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
309}
310
311fn header_string(headers: &HeaderMap, name: &HeaderName) -> Option<String> {
313 headers
314 .get(name)
315 .and_then(|v| v.to_str().ok())
316 .map(str::to_owned)
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 use std::path::PathBuf;
324
325 fn client() -> HttpClient {
327 HttpClient::new("rss-cli-test", Duration::from_secs(10)).expect("build client")
328 }
329
330 fn temp_cache_dir(tag: &str) -> PathBuf {
332 let dir = std::env::temp_dir().join(format!("rss-fetch-test-{}-{tag}", std::process::id()));
333 std::fs::create_dir_all(&dir).expect("create temp cache dir");
334 dir
335 }
336
337 #[tokio::test]
338 async fn fetch_200_stores_body_and_validators() {
339 let mut server = mockito::Server::new_async().await;
340 let mock = server
341 .mock("GET", "/feed.xml")
342 .with_status(200)
343 .with_header("etag", "\"v1\"")
344 .with_header("content-type", "application/rss+xml")
345 .with_body("<rss>fresh</rss>")
346 .create_async()
347 .await;
348
349 let dir = temp_cache_dir("store");
350 let cache = Cache::open(Some(dir.clone())).expect("open cache");
351 let url = format!("{}/feed.xml", server.url());
352
353 let raw = client()
354 .fetch(&url, &cache, CachePolicy::Revalidate)
355 .await
356 .expect("fetch ok");
357
358 assert_eq!(raw.status, 200);
359 assert!(!raw.from_cache);
360 assert!(!raw.not_modified);
361 assert_eq!(raw.body, b"<rss>fresh</rss>".to_vec());
362 assert_eq!(raw.content_type.as_deref(), Some("application/rss+xml"));
363
364 let entry = cache.get(&url).expect("cache get").expect("entry present");
366 assert_eq!(entry.body, b"<rss>fresh</rss>".to_vec());
367 assert_eq!(entry.meta.etag.as_deref(), Some("\"v1\""));
368
369 mock.assert_async().await;
370 std::fs::remove_dir_all(&dir).ok();
371 }
372
373 #[tokio::test]
374 async fn revalidate_304_reuses_cached_body() {
375 let mut server = mockito::Server::new_async().await;
376 let dir = temp_cache_dir("revalidate");
377 let cache = Cache::open(Some(dir.clone())).expect("open cache");
378 let url = format!("{}/feed.xml", server.url());
379
380 let seeded = "2020-01-01T00:00:00Z".to_string();
383 let meta = CacheMeta {
384 feed_url: url.clone(),
385 etag: Some("\"v1\"".to_string()),
386 last_modified: None,
387 fetched_at: seeded.clone(),
388 content_type: Some("application/rss+xml".to_string()),
389 };
390 cache.put(&meta, b"<rss>cached</rss>").expect("seed cache");
391
392 let mock = server
393 .mock("GET", "/feed.xml")
394 .match_header("if-none-match", "\"v1\"")
395 .with_status(304)
396 .create_async()
397 .await;
398
399 let raw = client()
400 .fetch(&url, &cache, CachePolicy::Revalidate)
401 .await
402 .expect("fetch ok");
403
404 assert_eq!(raw.status, 304);
405 assert!(raw.from_cache);
406 assert!(raw.not_modified);
407 assert_eq!(raw.body, b"<rss>cached</rss>".to_vec());
408 assert_eq!(raw.content_type.as_deref(), Some("application/rss+xml"));
409
410 mock.assert_async().await;
412 let entry = cache.get(&url).expect("cache get").expect("entry present");
413 assert_ne!(entry.meta.fetched_at, seeded);
414 assert_eq!(entry.meta.etag.as_deref(), Some("\"v1\""));
415
416 std::fs::remove_dir_all(&dir).ok();
417 }
418
419 #[tokio::test]
420 async fn maxage_serves_cache_without_network() {
421 let mut server = mockito::Server::new_async().await;
422 let dir = temp_cache_dir("maxage");
423 let cache = Cache::open(Some(dir.clone())).expect("open cache");
424 let url = format!("{}/feed.xml", server.url());
425
426 let meta = CacheMeta {
427 feed_url: url.clone(),
428 etag: Some("\"v1\"".to_string()),
429 last_modified: None,
430 fetched_at: now_rfc3339(),
431 content_type: Some("application/atom+xml".to_string()),
432 };
433 cache
434 .put(&meta, b"<feed>cached</feed>")
435 .expect("seed cache");
436
437 let mock = server
440 .mock("GET", "/feed.xml")
441 .with_status(200)
442 .with_body("<feed>network</feed>")
443 .expect(0)
444 .create_async()
445 .await;
446
447 let raw = client()
448 .fetch(&url, &cache, CachePolicy::MaxAge(Duration::from_secs(3600)))
449 .await
450 .expect("fetch ok");
451
452 assert_eq!(raw.status, 200);
453 assert!(raw.from_cache);
454 assert!(raw.not_modified);
455 assert_eq!(raw.body, b"<feed>cached</feed>".to_vec());
457 assert_eq!(raw.content_type.as_deref(), Some("application/atom+xml"));
458
459 mock.assert_async().await; std::fs::remove_dir_all(&dir).ok();
461 }
462
463 #[tokio::test]
464 async fn cache_first_serves_stale_cache_without_network() {
465 let mut server = mockito::Server::new_async().await;
466 let dir = temp_cache_dir("cachefirst");
467 let cache = Cache::open(Some(dir.clone())).expect("open cache");
468 let url = format!("{}/feed.xml", server.url());
469
470 let meta = CacheMeta {
472 feed_url: url.clone(),
473 etag: Some("\"v1\"".to_string()),
474 last_modified: None,
475 fetched_at: "2020-01-01T00:00:00Z".to_string(),
476 content_type: Some("application/rss+xml".to_string()),
477 };
478 cache.put(&meta, b"<rss>cached</rss>").expect("seed cache");
479
480 let mock = server
482 .mock("GET", "/feed.xml")
483 .with_status(200)
484 .with_body("<rss>network</rss>")
485 .expect(0)
486 .create_async()
487 .await;
488
489 let raw = client()
490 .fetch(&url, &cache, CachePolicy::CacheFirst)
491 .await
492 .expect("fetch ok");
493
494 assert!(raw.from_cache);
495 assert!(raw.not_modified);
496 assert_eq!(raw.body, b"<rss>cached</rss>".to_vec());
497
498 mock.assert_async().await; std::fs::remove_dir_all(&dir).ok();
500 }
501
502 #[tokio::test]
503 async fn cache_first_fetches_on_miss() {
504 let mut server = mockito::Server::new_async().await;
505 let mock = server
506 .mock("GET", "/feed.xml")
507 .with_status(200)
508 .with_body("<rss>fresh</rss>")
509 .create_async()
510 .await;
511 let dir = temp_cache_dir("cachefirst-miss");
512 let cache = Cache::open(Some(dir.clone())).expect("open cache");
513 let url = format!("{}/feed.xml", server.url());
514
515 let raw = client()
516 .fetch(&url, &cache, CachePolicy::CacheFirst)
517 .await
518 .expect("fetch ok");
519
520 assert!(!raw.from_cache);
521 assert_eq!(raw.body, b"<rss>fresh</rss>".to_vec());
522 mock.assert_async().await;
523 std::fs::remove_dir_all(&dir).ok();
524 }
525
526 #[tokio::test]
527 async fn retries_once_on_403_then_succeeds() {
528 let mut server = mockito::Server::new_async().await;
529 let dir = temp_cache_dir("retry-ok");
530 let cache = Cache::open(Some(dir.clone())).expect("open cache");
531 let url = format!("{}/feed.xml", server.url());
532
533 let m403 = server
534 .mock("GET", "/feed.xml")
535 .with_status(403)
536 .expect(1)
537 .create_async()
538 .await;
539 let m200 = server
540 .mock("GET", "/feed.xml")
541 .with_status(200)
542 .with_body("<rss>ok</rss>")
543 .expect(1)
544 .create_async()
545 .await;
546
547 let raw = client()
548 .fetch(&url, &cache, CachePolicy::NoCache)
549 .await
550 .expect("retry should succeed");
551 assert_eq!(raw.body, b"<rss>ok</rss>".to_vec());
552
553 m403.assert_async().await;
554 m200.assert_async().await;
555 std::fs::remove_dir_all(&dir).ok();
556 }
557
558 #[tokio::test]
559 async fn persistent_403_surfaces_status_in_error() {
560 let mut server = mockito::Server::new_async().await;
561 let dir = temp_cache_dir("retry-fail");
562 let cache = Cache::open(Some(dir.clone())).expect("open cache");
563 let url = format!("{}/feed.xml", server.url());
564
565 let m = server
567 .mock("GET", "/feed.xml")
568 .with_status(403)
569 .expect(2)
570 .create_async()
571 .await;
572
573 let err = client()
574 .fetch(&url, &cache, CachePolicy::NoCache)
575 .await
576 .unwrap_err();
577 match err {
578 RssError::Http { status, .. } => assert_eq!(status, 403),
579 other => panic!("expected Http error, got {other:?}"),
580 }
581 m.assert_async().await; std::fs::remove_dir_all(&dir).ok();
583 }
584}