Skip to main content

rss_cli/
fetch.rs

1//! HTTP client with conditional GET. **Owner: `fetcher` agent.**
2//!
3//! Frozen public interface — [`crate::core`] depends on these exact signatures. Implement
4//! the bodies; do not change the signatures without coordinating with the team lead.
5//!
6//! ## Requirements
7//! - Build a `reqwest::Client` with gzip, a sane redirect policy, the given timeout, and
8//!   the provided User-Agent.
9//! - Honor [`CachePolicy`]:
10//!   - `NoCache`: plain GET, never read or write the cache.
11//!   - `MaxAge(d)`: if a cache entry exists and is younger than `d`, return it without any
12//!     network call (`from_cache = true`, `not_modified = true`). Otherwise revalidate.
13//!   - `CacheFirst`: if a cache entry exists, return it without any network call regardless
14//!     of age (`from_cache = true`, `not_modified = true`); only a cache miss falls through
15//!     to a conditional GET. Used by item lookup so a rolled feed window cannot evict an item
16//!     the caller already saw (ADR-0014).
17//!   - `Revalidate` (default): send `If-None-Match` (etag) / `If-Modified-Since`
18//!     (last_modified) from the cache entry if present. On `304`, return the cached body
19//!     with `not_modified = true`, `from_cache = true`. On `200`, store the new body +
20//!     validators (`ETag`, `Last-Modified`) in the cache and return it.
21//! - On a non-success, non-304 status, return [`RssError::Http`].
22//! - `final_url` is the URL after following redirects (used to resolve relative links).
23
24use 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/// Raw bytes of a fetched (or cached) feed, plus the metadata `parse`/`core` need.
38#[derive(Debug, Clone)]
39pub struct RawFeed {
40    pub body: Vec<u8>,
41    /// URL after redirects (use to resolve relative item links).
42    pub final_url: String,
43    pub content_type: Option<String>,
44    pub status: u16,
45    /// True when served from cache via a `304` (or a `MaxAge`/`CacheFirst` hit).
46    pub not_modified: bool,
47    /// True when the returned body came from the cache rather than a fresh `200` body.
48    pub from_cache: bool,
49}
50
51/// Reusable HTTP client.
52#[derive(Clone)]
53pub struct HttpClient {
54    #[allow(dead_code)]
55    inner: reqwest::Client,
56}
57
58impl HttpClient {
59    /// Build a client with the given User-Agent and per-request timeout.
60    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    /// Fetch `url`, applying the cache `policy`. See module docs for the contract.
71    pub async fn fetch(
72        &self,
73        url: &str,
74        cache: &Cache,
75        policy: CachePolicy,
76    ) -> Result<RawFeed, RssError> {
77        match policy {
78            // Never touch the cache: a plain GET returning whatever the server sends.
79            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            // Serve a still-fresh cache entry without any network round-trip.
107            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            // Serve the cached body if present, regardless of age — never revalidate.
124            // Only a cache miss falls through to a normal conditional GET. See ADR-0014.
125            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            // Default: conditional GET, letting a `304` reuse the cached body.
140            CachePolicy::Revalidate => self.revalidate(url, cache).await,
141        }
142    }
143
144    /// Conditional GET: attach validators from any cache entry, reuse the cached body on a
145    /// `304`, and otherwise store and return the fresh `200` response.
146    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        // `304 Not Modified`: the cached body still stands; refresh only `fetched_at`.
167        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        // `200 OK`: capture validators, store the new body, and return it.
200        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    /// Plain GET returning the raw body (used by `discover` for the homepage HTML).
229    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
249/// Statuses we retry exactly once — transient provider rate-limiting.
250fn is_retryable(status: StatusCode) -> bool {
251    status == StatusCode::FORBIDDEN || status == StatusCode::TOO_MANY_REQUESTS
252}
253
254/// Parse the delta-seconds form of `Retry-After`, bounded to `max`. The HTTP-date form is
255/// intentionally ignored (Reddit sends delta-seconds) rather than risk a wrong sleep.
256fn 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
262/// Raw `Retry-After` header value, for surfacing in the error detail.
263fn 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
273/// Send a freshly-built request, retrying once on a transient 403/429. `build` is called
274/// again for the retry so headers/validators are re-attached cleanly.
275async 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
294/// `true` if a cache entry written at `fetched_at` (RFC-3339) is younger than `max_age`.
295fn 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        // An absurdly large window — treat any existing entry as fresh.
301        return true;
302    };
303    Utc::now().signed_duration_since(fetched) < max_age
304}
305
306/// Current UTC time as an RFC-3339 string (seconds precision, `Z` suffix).
307fn now_rfc3339() -> String {
308    Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
309}
310
311/// Extract a response header as an owned `String`, if present and valid UTF-8.
312fn 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    /// Build a client with a short timeout for tests.
326    fn client() -> HttpClient {
327        HttpClient::new("rss-cli-test", Duration::from_secs(10)).expect("build client")
328    }
329
330    /// A per-test temp cache dir (tag keeps parallel tests from colliding).
331    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        // The body and validators must have landed in the cache.
365        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        // Seed the cache with a validator and a deliberately stale timestamp so we can
381        // confirm the `304` refreshes `fetched_at`.
382        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        // The conditional GET fired and `fetched_at` was refreshed (validators kept).
411        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        // A network hit would be a bug: this mock returns a *different* body and must
438        // never be matched.
439        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        // The cached body (not the mock's) proves no network round-trip happened.
456        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; // expect(0): fails if the network was hit.
460        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        // Deliberately STALE timestamp: CacheFirst must ignore age entirely.
471        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        // Any network hit is a bug.
481        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; // expect(0): fails if the network was hit.
499        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        // Two 403s (original + one retry), then assert we still error out.
566        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; // exactly 2 attempts: one retry, no more.
582        std::fs::remove_dir_all(&dir).ok();
583    }
584}