1use std::sync::Arc;
25use std::time::Duration;
26
27use chrono::{DateTime, Utc};
28use reqwest::StatusCode;
29use reqwest::header::{
30 CONTENT_TYPE, ETAG, HeaderMap, HeaderName, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED,
31};
32use tokio::time::sleep;
33
34use crate::cache::{Cache, CacheMeta};
35use crate::config::CachePolicy;
36use crate::error::RssError;
37use crate::ratelimit::HostGate;
38
39#[derive(Debug, Clone)]
41pub struct RawFeed {
42 pub body: Vec<u8>,
43 pub final_url: String,
45 pub content_type: Option<String>,
46 pub status: u16,
47 pub not_modified: bool,
49 pub from_cache: bool,
51}
52
53#[derive(Clone)]
59pub struct HttpClient {
60 inner: reqwest::Client,
61 gate: Arc<HostGate>,
63}
64
65impl HttpClient {
66 pub fn new(user_agent: &str, timeout: Duration) -> Result<Self, RssError> {
68 let inner = reqwest::Client::builder()
69 .user_agent(user_agent)
70 .timeout(timeout)
71 .gzip(true)
72 .build()
73 .map_err(|e| RssError::Network(e.to_string()))?;
74 Ok(Self {
75 inner,
76 gate: Arc::new(HostGate::from_env()),
77 })
78 }
79
80 pub async fn fetch(
82 &self,
83 url: &str,
84 cache: &Cache,
85 policy: CachePolicy,
86 ) -> Result<RawFeed, RssError> {
87 match policy {
88 CachePolicy::NoCache => {
90 let resp = self.gated_send(url, || self.inner.get(url)).await?;
91 let status = resp.status();
92 let final_url = resp.url().to_string();
93 if !status.is_success() {
94 return Err(RssError::Http {
95 status: status.as_u16(),
96 url: url.to_string(),
97 retry_after: retry_after_raw(resp.headers()),
98 });
99 }
100 let content_type = header_string(resp.headers(), &CONTENT_TYPE);
101 let body = resp
102 .bytes()
103 .await
104 .map_err(|e| RssError::Network(e.to_string()))?
105 .to_vec();
106 Ok(RawFeed {
107 body,
108 final_url,
109 content_type,
110 status: status.as_u16(),
111 not_modified: false,
112 from_cache: false,
113 })
114 }
115
116 CachePolicy::MaxAge(max_age) => {
118 if let Some(entry) = cache.get(url)?
119 && is_fresh(&entry.meta.fetched_at, max_age)
120 {
121 return Ok(RawFeed {
122 body: entry.body,
123 final_url: url.to_string(),
124 content_type: entry.meta.content_type,
125 status: 200,
126 not_modified: true,
127 from_cache: true,
128 });
129 }
130 self.revalidate(url, cache).await
131 }
132
133 CachePolicy::CacheFirst => {
136 if let Some(entry) = cache.get(url)? {
137 return Ok(RawFeed {
138 body: entry.body,
139 final_url: url.to_string(),
140 content_type: entry.meta.content_type,
141 status: 200,
142 not_modified: true,
143 from_cache: true,
144 });
145 }
146 self.revalidate(url, cache).await
147 }
148
149 CachePolicy::Revalidate => self.revalidate(url, cache).await,
151 }
152 }
153
154 async fn revalidate(&self, url: &str, cache: &Cache) -> Result<RawFeed, RssError> {
157 let cached = cache.get(url)?;
158
159 let build = || {
160 let mut req = self.inner.get(url);
161 if let Some(entry) = &cached {
162 if let Some(etag) = &entry.meta.etag {
163 req = req.header(IF_NONE_MATCH, etag.as_str());
164 }
165 if let Some(last_modified) = &entry.meta.last_modified {
166 req = req.header(IF_MODIFIED_SINCE, last_modified.as_str());
167 }
168 }
169 req
170 };
171
172 let resp = self.gated_send(url, build).await?;
173 let status = resp.status();
174 let final_url = resp.url().to_string();
175
176 if status == StatusCode::NOT_MODIFIED {
178 let entry = cached.ok_or_else(|| {
179 RssError::Network(format!(
180 "server returned 304 but no cache entry exists for {url}"
181 ))
182 })?;
183 let meta = CacheMeta {
184 feed_url: url.to_string(),
185 etag: entry.meta.etag.clone(),
186 last_modified: entry.meta.last_modified.clone(),
187 fetched_at: now_rfc3339(),
188 content_type: entry.meta.content_type.clone(),
189 };
190 cache.put(&meta, &entry.body)?;
191 return Ok(RawFeed {
192 body: entry.body,
193 final_url,
194 content_type: entry.meta.content_type,
195 status: StatusCode::NOT_MODIFIED.as_u16(),
196 not_modified: true,
197 from_cache: true,
198 });
199 }
200
201 if !status.is_success() {
202 return Err(RssError::Http {
203 status: status.as_u16(),
204 url: url.to_string(),
205 retry_after: retry_after_raw(resp.headers()),
206 });
207 }
208
209 let content_type = header_string(resp.headers(), &CONTENT_TYPE);
211 let etag = header_string(resp.headers(), &ETAG);
212 let last_modified = header_string(resp.headers(), &LAST_MODIFIED);
213 let body = resp
214 .bytes()
215 .await
216 .map_err(|e| RssError::Network(e.to_string()))?
217 .to_vec();
218
219 let meta = CacheMeta {
220 feed_url: url.to_string(),
221 etag,
222 last_modified,
223 fetched_at: now_rfc3339(),
224 content_type: content_type.clone(),
225 };
226 cache.put(&meta, &body)?;
227
228 Ok(RawFeed {
229 body,
230 final_url,
231 content_type,
232 status: status.as_u16(),
233 not_modified: false,
234 from_cache: false,
235 })
236 }
237
238 pub async fn get_bytes(&self, url: &str) -> Result<(Vec<u8>, String), RssError> {
240 let resp = self.gated_send(url, || self.inner.get(url)).await?;
241 let status = resp.status();
242 let final_url = resp.url().to_string();
243 if !status.is_success() {
244 return Err(RssError::Http {
245 status: status.as_u16(),
246 url: url.to_string(),
247 retry_after: retry_after_raw(resp.headers()),
248 });
249 }
250 let body = resp
251 .bytes()
252 .await
253 .map_err(|e| RssError::Network(e.to_string()))?
254 .to_vec();
255 Ok((body, final_url))
256 }
257}
258
259fn is_retryable(status: StatusCode) -> bool {
261 status == StatusCode::FORBIDDEN || status == StatusCode::TOO_MANY_REQUESTS
262}
263
264fn retry_after(headers: &HeaderMap, max: Duration) -> Option<Duration> {
267 let raw = headers.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?;
268 let secs: u64 = raw.trim().parse().ok()?;
269 Some(Duration::from_secs(secs).min(max))
270}
271
272fn retry_after_raw(headers: &HeaderMap) -> Option<String> {
274 headers
275 .get(reqwest::header::RETRY_AFTER)
276 .and_then(|v| v.to_str().ok())
277 .map(str::to_owned)
278}
279
280const RETRY_BASE_DELAY: Duration = Duration::from_millis(500);
281const RETRY_MAX_DELAY: Duration = Duration::from_secs(5);
282
283impl HttpClient {
284 async fn gated_send<F>(&self, url: &str, build: F) -> Result<reqwest::Response, RssError>
293 where
294 F: Fn() -> reqwest::RequestBuilder,
295 {
296 let _permit = self.gate.acquire(url).await?;
300
301 let resp = build()
302 .send()
303 .await
304 .map_err(|e| RssError::Network(e.to_string()))?;
305 if !is_retryable(resp.status()) {
306 self.gate.note_success(url);
307 return Ok(resp);
308 }
309
310 self.gate
312 .note_throttled(url, retry_after_duration(resp.headers()));
313 let wait = retry_after(resp.headers(), RETRY_MAX_DELAY).unwrap_or(RETRY_BASE_DELAY);
314 sleep(wait).await;
315 let resp = build()
316 .send()
317 .await
318 .map_err(|e| RssError::Network(e.to_string()))?;
319 if is_retryable(resp.status()) {
320 self.gate
321 .note_throttled(url, retry_after_duration(resp.headers()));
322 } else {
323 self.gate.note_success(url);
324 }
325 Ok(resp)
326 }
327}
328
329fn retry_after_duration(headers: &HeaderMap) -> Option<Duration> {
334 let raw = headers.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?;
335 let raw = raw.trim();
336 if let Ok(secs) = raw.parse::<u64>() {
337 return Some(Duration::from_secs(secs));
338 }
339 let when = DateTime::parse_from_rfc2822(raw).ok()?;
341 (when.with_timezone(&Utc) - Utc::now()).to_std().ok()
342}
343
344fn is_fresh(fetched_at: &str, max_age: Duration) -> bool {
346 let Ok(fetched) = DateTime::parse_from_rfc3339(fetched_at) else {
347 return false;
348 };
349 let Ok(max_age) = chrono::Duration::from_std(max_age) else {
350 return true;
352 };
353 Utc::now().signed_duration_since(fetched) < max_age
354}
355
356fn now_rfc3339() -> String {
358 Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
359}
360
361fn header_string(headers: &HeaderMap, name: &HeaderName) -> Option<String> {
363 headers
364 .get(name)
365 .and_then(|v| v.to_str().ok())
366 .map(str::to_owned)
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 use std::path::PathBuf;
374
375 fn client() -> HttpClient {
377 HttpClient::new("rss-cli-test", Duration::from_secs(10)).expect("build client")
378 }
379
380 fn headers_with_retry_after(value: &str) -> HeaderMap {
381 let mut h = HeaderMap::new();
382 h.insert(reqwest::header::RETRY_AFTER, value.parse().unwrap());
383 h
384 }
385
386 #[test]
387 fn retry_after_parses_delta_seconds_and_caps_to_max() {
388 assert_eq!(
390 retry_after(&headers_with_retry_after("2"), RETRY_MAX_DELAY),
391 Some(Duration::from_secs(2))
392 );
393 assert_eq!(
394 retry_after(&headers_with_retry_after("30"), RETRY_MAX_DELAY),
395 Some(RETRY_MAX_DELAY),
396 "a value above the cap must clamp to RETRY_MAX_DELAY"
397 );
398 assert_eq!(retry_after(&HeaderMap::new(), RETRY_MAX_DELAY), None);
399 }
400
401 #[test]
402 fn retry_after_duration_parses_delta_seconds_and_http_date() {
403 assert_eq!(
405 retry_after_duration(&headers_with_retry_after("45")),
406 Some(Duration::from_secs(45))
407 );
408 let future =
410 retry_after_duration(&headers_with_retry_after("Wed, 21 Oct 2099 07:28:00 GMT"))
411 .expect("a future HTTP-date should parse to a positive duration");
412 assert!(future > Duration::from_secs(0));
413 assert_eq!(
415 retry_after_duration(&headers_with_retry_after("Wed, 21 Oct 2015 07:28:00 GMT")),
416 None
417 );
418 assert_eq!(
420 retry_after_duration(&headers_with_retry_after("soon")),
421 None
422 );
423 assert_eq!(retry_after_duration(&HeaderMap::new()), None);
424 }
425
426 fn temp_cache_dir(tag: &str) -> PathBuf {
428 let dir = std::env::temp_dir().join(format!("rss-fetch-test-{}-{tag}", std::process::id()));
429 std::fs::create_dir_all(&dir).expect("create temp cache dir");
430 dir
431 }
432
433 #[tokio::test]
434 async fn fetch_200_stores_body_and_validators() {
435 let mut server = mockito::Server::new_async().await;
436 let mock = server
437 .mock("GET", "/feed.xml")
438 .with_status(200)
439 .with_header("etag", "\"v1\"")
440 .with_header("content-type", "application/rss+xml")
441 .with_body("<rss>fresh</rss>")
442 .create_async()
443 .await;
444
445 let dir = temp_cache_dir("store");
446 let cache = Cache::open(Some(dir.clone())).expect("open cache");
447 let url = format!("{}/feed.xml", server.url());
448
449 let raw = client()
450 .fetch(&url, &cache, CachePolicy::Revalidate)
451 .await
452 .expect("fetch ok");
453
454 assert_eq!(raw.status, 200);
455 assert!(!raw.from_cache);
456 assert!(!raw.not_modified);
457 assert_eq!(raw.body, b"<rss>fresh</rss>".to_vec());
458 assert_eq!(raw.content_type.as_deref(), Some("application/rss+xml"));
459
460 let entry = cache.get(&url).expect("cache get").expect("entry present");
462 assert_eq!(entry.body, b"<rss>fresh</rss>".to_vec());
463 assert_eq!(entry.meta.etag.as_deref(), Some("\"v1\""));
464
465 mock.assert_async().await;
466 std::fs::remove_dir_all(&dir).ok();
467 }
468
469 #[tokio::test]
470 async fn revalidate_304_reuses_cached_body() {
471 let mut server = mockito::Server::new_async().await;
472 let dir = temp_cache_dir("revalidate");
473 let cache = Cache::open(Some(dir.clone())).expect("open cache");
474 let url = format!("{}/feed.xml", server.url());
475
476 let seeded = "2020-01-01T00:00:00Z".to_string();
479 let meta = CacheMeta {
480 feed_url: url.clone(),
481 etag: Some("\"v1\"".to_string()),
482 last_modified: None,
483 fetched_at: seeded.clone(),
484 content_type: Some("application/rss+xml".to_string()),
485 };
486 cache.put(&meta, b"<rss>cached</rss>").expect("seed cache");
487
488 let mock = server
489 .mock("GET", "/feed.xml")
490 .match_header("if-none-match", "\"v1\"")
491 .with_status(304)
492 .create_async()
493 .await;
494
495 let raw = client()
496 .fetch(&url, &cache, CachePolicy::Revalidate)
497 .await
498 .expect("fetch ok");
499
500 assert_eq!(raw.status, 304);
501 assert!(raw.from_cache);
502 assert!(raw.not_modified);
503 assert_eq!(raw.body, b"<rss>cached</rss>".to_vec());
504 assert_eq!(raw.content_type.as_deref(), Some("application/rss+xml"));
505
506 mock.assert_async().await;
508 let entry = cache.get(&url).expect("cache get").expect("entry present");
509 assert_ne!(entry.meta.fetched_at, seeded);
510 assert_eq!(entry.meta.etag.as_deref(), Some("\"v1\""));
511
512 std::fs::remove_dir_all(&dir).ok();
513 }
514
515 #[tokio::test]
516 async fn maxage_serves_cache_without_network() {
517 let mut server = mockito::Server::new_async().await;
518 let dir = temp_cache_dir("maxage");
519 let cache = Cache::open(Some(dir.clone())).expect("open cache");
520 let url = format!("{}/feed.xml", server.url());
521
522 let meta = CacheMeta {
523 feed_url: url.clone(),
524 etag: Some("\"v1\"".to_string()),
525 last_modified: None,
526 fetched_at: now_rfc3339(),
527 content_type: Some("application/atom+xml".to_string()),
528 };
529 cache
530 .put(&meta, b"<feed>cached</feed>")
531 .expect("seed cache");
532
533 let mock = server
536 .mock("GET", "/feed.xml")
537 .with_status(200)
538 .with_body("<feed>network</feed>")
539 .expect(0)
540 .create_async()
541 .await;
542
543 let raw = client()
544 .fetch(&url, &cache, CachePolicy::MaxAge(Duration::from_secs(3600)))
545 .await
546 .expect("fetch ok");
547
548 assert_eq!(raw.status, 200);
549 assert!(raw.from_cache);
550 assert!(raw.not_modified);
551 assert_eq!(raw.body, b"<feed>cached</feed>".to_vec());
553 assert_eq!(raw.content_type.as_deref(), Some("application/atom+xml"));
554
555 mock.assert_async().await; std::fs::remove_dir_all(&dir).ok();
557 }
558
559 #[tokio::test]
560 async fn cache_first_serves_stale_cache_without_network() {
561 let mut server = mockito::Server::new_async().await;
562 let dir = temp_cache_dir("cachefirst");
563 let cache = Cache::open(Some(dir.clone())).expect("open cache");
564 let url = format!("{}/feed.xml", server.url());
565
566 let meta = CacheMeta {
568 feed_url: url.clone(),
569 etag: Some("\"v1\"".to_string()),
570 last_modified: None,
571 fetched_at: "2020-01-01T00:00:00Z".to_string(),
572 content_type: Some("application/rss+xml".to_string()),
573 };
574 cache.put(&meta, b"<rss>cached</rss>").expect("seed cache");
575
576 let mock = server
578 .mock("GET", "/feed.xml")
579 .with_status(200)
580 .with_body("<rss>network</rss>")
581 .expect(0)
582 .create_async()
583 .await;
584
585 let raw = client()
586 .fetch(&url, &cache, CachePolicy::CacheFirst)
587 .await
588 .expect("fetch ok");
589
590 assert!(raw.from_cache);
591 assert!(raw.not_modified);
592 assert_eq!(raw.body, b"<rss>cached</rss>".to_vec());
593
594 mock.assert_async().await; std::fs::remove_dir_all(&dir).ok();
596 }
597
598 #[tokio::test]
599 async fn cache_first_fetches_on_miss() {
600 let mut server = mockito::Server::new_async().await;
601 let mock = server
602 .mock("GET", "/feed.xml")
603 .with_status(200)
604 .with_body("<rss>fresh</rss>")
605 .create_async()
606 .await;
607 let dir = temp_cache_dir("cachefirst-miss");
608 let cache = Cache::open(Some(dir.clone())).expect("open cache");
609 let url = format!("{}/feed.xml", server.url());
610
611 let raw = client()
612 .fetch(&url, &cache, CachePolicy::CacheFirst)
613 .await
614 .expect("fetch ok");
615
616 assert!(!raw.from_cache);
617 assert_eq!(raw.body, b"<rss>fresh</rss>".to_vec());
618 mock.assert_async().await;
619 std::fs::remove_dir_all(&dir).ok();
620 }
621
622 #[tokio::test]
623 async fn retries_once_on_403_then_succeeds() {
624 let mut server = mockito::Server::new_async().await;
625 let dir = temp_cache_dir("retry-ok");
626 let cache = Cache::open(Some(dir.clone())).expect("open cache");
627 let url = format!("{}/feed.xml", server.url());
628
629 let m403 = server
630 .mock("GET", "/feed.xml")
631 .with_status(403)
632 .expect(1)
633 .create_async()
634 .await;
635 let m200 = server
636 .mock("GET", "/feed.xml")
637 .with_status(200)
638 .with_body("<rss>ok</rss>")
639 .expect(1)
640 .create_async()
641 .await;
642
643 let raw = client()
644 .fetch(&url, &cache, CachePolicy::NoCache)
645 .await
646 .expect("retry should succeed");
647 assert_eq!(raw.body, b"<rss>ok</rss>".to_vec());
648
649 m403.assert_async().await;
650 m200.assert_async().await;
651 std::fs::remove_dir_all(&dir).ok();
652 }
653
654 #[tokio::test]
655 async fn persistent_403_surfaces_status_in_error() {
656 let mut server = mockito::Server::new_async().await;
657 let dir = temp_cache_dir("retry-fail");
658 let cache = Cache::open(Some(dir.clone())).expect("open cache");
659 let url = format!("{}/feed.xml", server.url());
660
661 let m = server
663 .mock("GET", "/feed.xml")
664 .with_status(403)
665 .expect(2)
666 .create_async()
667 .await;
668
669 let err = client()
670 .fetch(&url, &cache, CachePolicy::NoCache)
671 .await
672 .unwrap_err();
673 match err {
674 RssError::Http { status, .. } => assert_eq!(status, 403),
675 other => panic!("expected Http error, got {other:?}"),
676 }
677 m.assert_async().await; std::fs::remove_dir_all(&dir).ok();
679 }
680}