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::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/// Raw bytes of a fetched (or cached) feed, plus the metadata `parse`/`core` need.
40#[derive(Debug, Clone)]
41pub struct RawFeed {
42    pub body: Vec<u8>,
43    /// URL after redirects (use to resolve relative item links).
44    pub final_url: String,
45    pub content_type: Option<String>,
46    pub status: u16,
47    /// True when served from cache via a `304` (or a `MaxAge`/`CacheFirst` hit).
48    pub not_modified: bool,
49    /// True when the returned body came from the cache rather than a fresh `200` body.
50    pub from_cache: bool,
51}
52
53/// Reusable HTTP client.
54///
55/// Reuse it: cloning is cheap (both fields are `Arc`-backed) and a clone **shares the same
56/// per-host gate and connection pool**. The MCP server builds one and shares it across tool
57/// calls so concurrent calls coordinate their pacing (ADR-0016); the CLI builds one per run.
58#[derive(Clone)]
59pub struct HttpClient {
60    inner: reqwest::Client,
61    /// Shared per-host request gate (ADR-0016).
62    gate: Arc<HostGate>,
63}
64
65impl HttpClient {
66    /// Build a client with the given User-Agent and per-request timeout.
67    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    /// Fetch `url`, applying the cache `policy`. See module docs for the contract.
81    pub async fn fetch(
82        &self,
83        url: &str,
84        cache: &Cache,
85        policy: CachePolicy,
86    ) -> Result<RawFeed, RssError> {
87        match policy {
88            // Never touch the cache: a plain GET returning whatever the server sends.
89            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            // Serve a still-fresh cache entry without any network round-trip.
117            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            // Serve the cached body if present, regardless of age — never revalidate.
134            // Only a cache miss falls through to a normal conditional GET. See ADR-0014.
135            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            // Default: conditional GET, letting a `304` reuse the cached body.
150            CachePolicy::Revalidate => self.revalidate(url, cache).await,
151        }
152    }
153
154    /// Conditional GET: attach validators from any cache entry, reuse the cached body on a
155    /// `304`, and otherwise store and return the fresh `200` response.
156    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        // `304 Not Modified`: the cached body still stands; refresh only `fetched_at`.
177        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        // `200 OK`: capture validators, store the new body, and return it.
210        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    /// Plain GET returning the raw body (used by `discover` for the homepage HTML).
239    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
259/// Statuses we retry exactly once — transient provider rate-limiting.
260fn is_retryable(status: StatusCode) -> bool {
261    status == StatusCode::FORBIDDEN || status == StatusCode::TOO_MANY_REQUESTS
262}
263
264/// Parse the delta-seconds form of `Retry-After`, bounded to `max`. The HTTP-date form is
265/// intentionally ignored (Reddit sends delta-seconds) rather than risk a wrong sleep.
266fn 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
272/// Raw `Retry-After` header value, for surfacing in the error detail.
273fn 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    /// Gate-aware send: acquire the per-host permit (waiting out any active cooldown / sticky
285    /// spacing), then send with the single bounded ADR-0015 retry on a transient `403`/`429`.
286    /// `build` is called again for the retry so headers/validators are re-attached cleanly.
287    ///
288    /// The permit is held across the retry, so a request never blocks on the cooldown it
289    /// itself just set (the two waits are one budget — ADR-0016). Acquiring may instead return
290    /// [`RssError::RateLimited`] when a sibling's cooldown would make this request wait past
291    /// the gate ceiling.
292    async fn gated_send<F>(&self, url: &str, build: F) -> Result<reqwest::Response, RssError>
293    where
294        F: Fn() -> reqwest::RequestBuilder,
295    {
296        // The gate keys on the *request* URL's host. reqwest follows redirects internally, so
297        // a cross-host redirect's throttle is attributed to the origin host, not the final
298        // one — acceptable (callers hit one host consistently) and unavoidable pre-send.
299        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        // Transient 403/429: extend the sibling cooldown, then spend the single retry.
311        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
329/// Parse `Retry-After` into a `Duration` from now, accepting **both** the delta-seconds and
330/// the HTTP-date forms. ADR-0015 deferred the date form; ADR-0016 consumes it (the gate clamps
331/// the result), so a skewed or already-past date is harmless. `None` when absent, unparseable,
332/// or already in the past.
333fn 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    // HTTP-date form (RFC 1123, an RFC 2822 date).
340    let when = DateTime::parse_from_rfc2822(raw).ok()?;
341    (when.with_timezone(&Utc) - Utc::now()).to_std().ok()
342}
343
344/// `true` if a cache entry written at `fetched_at` (RFC-3339) is younger than `max_age`.
345fn 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        // An absurdly large window — treat any existing entry as fresh.
351        return true;
352    };
353    Utc::now().signed_duration_since(fetched) < max_age
354}
355
356/// Current UTC time as an RFC-3339 string (seconds precision, `Z` suffix).
357fn now_rfc3339() -> String {
358    Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
359}
360
361/// Extract a response header as an owned `String`, if present and valid UTF-8.
362fn 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    /// Build a client with a short timeout for tests.
376    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        // The in-flight ADR-0015 retry wait: delta-seconds, clamped to `max`.
389        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        // Delta-seconds (uncapped here — the gate clamps).
404        assert_eq!(
405            retry_after_duration(&headers_with_retry_after("45")),
406            Some(Duration::from_secs(45))
407        );
408        // A future HTTP-date yields a positive duration...
409        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        // ...a past date yields None (never a wrong/negative sleep)...
414        assert_eq!(
415            retry_after_duration(&headers_with_retry_after("Wed, 21 Oct 2015 07:28:00 GMT")),
416            None
417        );
418        // ...and garbage / absent yield None.
419        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    /// A per-test temp cache dir (tag keeps parallel tests from colliding).
427    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        // The body and validators must have landed in the cache.
461        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        // Seed the cache with a validator and a deliberately stale timestamp so we can
477        // confirm the `304` refreshes `fetched_at`.
478        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        // The conditional GET fired and `fetched_at` was refreshed (validators kept).
507        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        // A network hit would be a bug: this mock returns a *different* body and must
534        // never be matched.
535        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        // The cached body (not the mock's) proves no network round-trip happened.
552        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; // expect(0): fails if the network was hit.
556        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        // Deliberately STALE timestamp: CacheFirst must ignore age entirely.
567        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        // Any network hit is a bug.
577        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; // expect(0): fails if the network was hit.
595        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        // Two 403s (original + one retry), then assert we still error out.
662        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; // exactly 2 attempts: one retry, no more.
678        std::fs::remove_dir_all(&dir).ok();
679    }
680}