Skip to main content

ai_usagebar/
outcome.rs

1//! What a vendor fetch produced: a snapshot, plus how much to trust it.
2//!
3//! Every vendor answers the same four questions — what the numbers are,
4//! whether they came from a live call or a stale cache, what the last failure
5//! was, and how old the payload is — so every vendor declared the same struct
6//! and the same three helpers around it. Eighteen copies of a four-field
7//! record is only tedious; eighteen copies of the *policy* is a hazard, and it
8//! had already gone wrong twice in this family:
9//!
10//! - the cold-cache error, which five vendors replaced with a generic "no
11//!   usable cache" while thirteen returned the real one;
12//! - an unparseable cached payload, which some vendors reported as a cache
13//!   parse error and others as the original fetch failure.
14//!
15//! Both are settled here once. A vendor supplies its snapshot type and a
16//! closure that parses its own cache format; everything else is shared.
17
18use std::time::Duration;
19
20use crate::cache::{Cache, MAX_STALE};
21use crate::error::{AppError, Result};
22
23/// A snapshot with its provenance.
24#[derive(Debug, Clone)]
25pub struct Outcome<T> {
26    pub snapshot: T,
27    /// The payload is past its TTL — shown, but marked.
28    pub stale: bool,
29    /// The failure recorded by the most recent unsuccessful refresh, redacted
30    /// by [`Cache::write_last_error`]. `None` means the last refresh worked.
31    pub last_error: Option<(u16, String)>,
32    /// How long ago the payload was written. `None` when unknown.
33    pub cache_age: Option<Duration>,
34}
35
36impl<T> Outcome<T> {
37    /// Straight off the wire: fresh, no recorded error, zero age.
38    ///
39    /// Note the deliberate asymmetry with [`Outcome::cached`] — a successful
40    /// fetch clears `last_error` rather than reading it back, because the
41    /// error it would read is the one this call just superseded.
42    pub fn fresh(snapshot: T) -> Self {
43        Self {
44            snapshot,
45            stale: false,
46            last_error: None,
47            cache_age: Some(Duration::ZERO),
48        }
49    }
50
51    /// Parsed back out of the cache. The recorded error and the payload's age
52    /// come from the cache, so a warm-cache render still shows why the last
53    /// refresh failed.
54    pub fn cached(snapshot: T, cache: &Cache, stale: bool) -> Self {
55        Self {
56            snapshot,
57            stale,
58            last_error: cache.read_last_error(),
59            cache_age: cache.payload_age(),
60        }
61    }
62
63    /// Re-type the snapshot, keeping the provenance. This is how a vendor's
64    /// own outcome becomes a [`crate::vendor::VendorOutcome`].
65    pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Outcome<U> {
66        Outcome {
67            snapshot: f(self.snapshot),
68            stale: self.stale,
69            last_error: self.last_error,
70            cache_age: self.cache_age,
71        }
72    }
73
74    /// True only for an outcome built by [`Outcome::fresh`]: the payload came
75    /// off the wire in this process, not out of the cache. The signal is
76    /// `cache_age == Some(ZERO)` — a cached outcome's age is measured from
77    /// its payload's mtime and is therefore nonzero. Consumers that must not
78    /// act on replayed data (the notification check is the one today) gate
79    /// on this rather than re-deriving freshness from `stale`, which is
80    /// `false` for a within-TTL cache hit too.
81    pub fn off_the_wire(&self) -> bool {
82        !self.stale && self.cache_age == Some(Duration::ZERO)
83    }
84}
85
86/// Serve the last good payload after a failed refresh, or give up with the
87/// error that caused the failure.
88///
89/// `original` is returned — not a message about the cache — whenever there is
90/// nothing to show. With no figure on screen the error *is* the output, so it
91/// has to name the real cause: on a first run a rejected key, a `500` and an
92/// empty cache are otherwise indistinguishable. A cached payload that will not
93/// parse counts as nothing to show, and for the same reason reports `original`
94/// rather than the parse failure, which is an internal detail the user cannot
95/// act on.
96///
97/// `last_error` *overrides* what the cache recorded when it is `Some` — the
98/// caller has just written a fresher diagnostic and holds the redacted copy.
99/// `None` keeps whatever the cache holds, which is what a transient failure
100/// wants: it records nothing, so the previous error should stay visible.
101pub fn fallback<T>(
102    cache: &Cache,
103    last_error: Option<(u16, String)>,
104    original: AppError,
105    parse: impl FnOnce(&[u8]) -> Result<T>,
106) -> Result<Outcome<T>> {
107    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
108        return Err(original);
109    };
110    let Ok(snapshot) = parse(&bytes) else {
111        return Err(original);
112    };
113    let mut outcome = Outcome::cached(snapshot, cache, true);
114    if last_error.is_some() {
115        outcome.last_error = last_error;
116    }
117    Ok(outcome)
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use tempfile::TempDir;
124
125    fn fixture() -> (TempDir, Cache) {
126        let td = TempDir::new().unwrap();
127        let cache = Cache::at(td.path().join("vendor"));
128        cache.ensure_dir().unwrap();
129        (td, cache)
130    }
131
132    fn parse_ok(bytes: &[u8]) -> Result<String> {
133        Ok(String::from_utf8_lossy(bytes).into_owned())
134    }
135
136    fn parse_fails(_: &[u8]) -> Result<String> {
137        Err(AppError::Schema("cached payload is not ours".into()))
138    }
139
140    /// The refactor that introduced this module replaced the hand-built
141    /// records with `fresh`/`cached` by regex, and the regex missed six that
142    /// used field shorthand — behaviourally identical, but they are how the
143    /// policy drifts back apart. Constructing the record by hand is the thing
144    /// to forbid, not any particular spelling of it.
145    #[test]
146    fn no_vendor_assembles_the_record_by_hand() {
147        let mut sites = Vec::new();
148        for file in crate::guard::rs_files_in("src") {
149            if file.ends_with("outcome.rs") {
150                continue;
151            }
152            let source = std::fs::read_to_string(&file).expect("readable module");
153            for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
154                let line = line.trim();
155                if line == "cache_age: Some(Duration::ZERO),"
156                    || line == "cache_age: cache.payload_age(),"
157                {
158                    sites.push(format!("{}:{}", file.display(), n + 1));
159                }
160            }
161        }
162        assert!(
163            sites.is_empty(),
164            "build outcomes with `Outcome::fresh` or `Outcome::cached`, so the \
165             provenance rules live in one place. Found: {sites:#?}"
166        );
167    }
168
169    #[test]
170    fn a_fresh_outcome_clears_the_recorded_error() {
171        let out = Outcome::fresh("live");
172        assert_eq!(out.snapshot, "live");
173        assert!(!out.stale);
174        assert_eq!(out.last_error, None);
175        assert_eq!(out.cache_age, Some(Duration::ZERO));
176    }
177
178    #[test]
179    fn a_cached_outcome_carries_the_recorded_error() {
180        let (_td, cache) = fixture();
181        cache.write_last_error(500, "upstream is down");
182
183        let out = Outcome::cached("cached", &cache, true);
184
185        assert!(out.stale);
186        assert_eq!(out.last_error, Some((500, "upstream is down".to_string())));
187    }
188
189    #[test]
190    fn map_re_types_the_snapshot_and_keeps_the_provenance() {
191        let (_td, cache) = fixture();
192        cache.write_last_error(429, "slow down");
193        let out = Outcome::cached(7u8, &cache, true).map(|n| n as u32 * 2);
194
195        assert_eq!(out.snapshot, 14u32);
196        assert!(out.stale);
197        assert_eq!(out.last_error, Some((429, "slow down".to_string())));
198    }
199
200    /// Freshness as the notification check needs it: only a wire-fresh
201    /// outcome counts, and a within-TTL cache hit — which is also
202    /// `stale: false` — must not.
203    #[test]
204    fn off_the_wire_is_true_only_for_wire_fresh_outcomes() {
205        let fresh = Outcome::fresh("live");
206        assert!(fresh.off_the_wire());
207
208        let hand_cached = Outcome {
209            snapshot: "cached",
210            stale: false,
211            last_error: None,
212            cache_age: Some(Duration::from_secs(1)),
213        };
214        assert!(
215            !hand_cached.off_the_wire(),
216            "a within-TTL cache hit is not wire-fresh"
217        );
218
219        let stale = Outcome {
220            stale: true,
221            ..hand_cached
222        };
223        assert!(!stale.off_the_wire());
224
225        let unknown_age = Outcome {
226            snapshot: "cached",
227            stale: false,
228            last_error: None,
229            cache_age: None,
230        };
231        assert!(!unknown_age.off_the_wire());
232    }
233
234    /// The regression this closes: five vendors returned
235    /// `AppError::Other("… no usable cache")` here, so on a first run an
236    /// expired key and an empty cache produced the same tooltip.
237    #[test]
238    fn no_cache_returns_the_error_that_caused_the_failure() {
239        let (_td, cache) = fixture();
240
241        let err = fallback(
242            &cache,
243            Some((401, "Authentication failed".into())),
244            AppError::Http {
245                status: 401,
246                body: "Authentication failed".into(),
247            },
248            parse_ok,
249        )
250        .unwrap_err();
251
252        assert!(
253            matches!(err, AppError::Http { status: 401, .. }),
254            "got {err:?}"
255        );
256    }
257
258    /// A cached payload we cannot parse is no better than no payload — and the
259    /// parse failure is not the user's problem, the failed fetch is. Vendors
260    /// disagreed about this: some propagated the parse error with `?`.
261    #[test]
262    fn an_unparseable_cached_payload_also_reports_the_original_error() {
263        let (_td, cache) = fixture();
264        cache
265            .write_payload(b"payload from another account")
266            .unwrap();
267
268        let err = fallback(
269            &cache,
270            None,
271            AppError::Transport("network unreachable".into()),
272            parse_fails,
273        )
274        .unwrap_err();
275
276        assert!(
277            matches!(&err, AppError::Transport(m) if m == "network unreachable"),
278            "the cache parse failure masked the real cause: {err:?}"
279        );
280    }
281
282    #[test]
283    fn a_usable_cache_is_served_stale_with_the_fresher_diagnostic() {
284        let (_td, cache) = fixture();
285        cache.write_payload(b"last good").unwrap();
286        cache.write_last_error(500, "an older failure");
287
288        let out = fallback(
289            &cache,
290            Some((401, "Authentication failed".into())),
291            AppError::Http {
292                status: 401,
293                body: "raw".into(),
294            },
295            parse_ok,
296        )
297        .unwrap();
298
299        assert_eq!(out.snapshot, "last good");
300        assert!(out.stale);
301        assert_eq!(
302            out.last_error,
303            Some((401, "Authentication failed".to_string())),
304            "the caller's fresher diagnostic must win over the recorded one"
305        );
306    }
307
308    /// A transient failure records nothing, so it passes `None` and the error
309    /// already on disk stays visible rather than being blanked.
310    #[test]
311    fn a_silent_fallback_keeps_the_error_already_recorded() {
312        let (_td, cache) = fixture();
313        cache.write_payload(b"last good").unwrap();
314        cache.write_last_error(500, "an earlier failure");
315
316        let out = fallback(
317            &cache,
318            None,
319            AppError::Transport("network unreachable".into()),
320            parse_ok,
321        )
322        .unwrap();
323
324        assert_eq!(
325            out.last_error,
326            Some((500, "an earlier failure".to_string()))
327        );
328    }
329}