1use std::time::Duration;
19
20use crate::cache::{Cache, MAX_STALE};
21use crate::error::{AppError, Result};
22
23#[derive(Debug, Clone)]
25pub struct Outcome<T> {
26 pub snapshot: T,
27 pub stale: bool,
29 pub last_error: Option<(u16, String)>,
32 pub cache_age: Option<Duration>,
34}
35
36impl<T> Outcome<T> {
37 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 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 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 pub fn off_the_wire(&self) -> bool {
82 !self.stale && self.cache_age == Some(Duration::ZERO)
83 }
84}
85
86pub 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 #[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 #[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 #[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 #[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 #[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}