Skip to main content

self_update_extras/
throttle.rs

1//! Throttle wrapper: limits how often update checks run.
2
3use self_update::Status;
4use self_update::errors::{Error, Result};
5use self_update::update::{Release, ReleaseUpdate, UpdateStatus};
6use std::time::Duration;
7
8/// Default minimum time between update checks.
9const DEFAULT_THROTTLE_WINDOW: Duration = Duration::from_secs(15 * 60);
10
11/// Builder for a throttled [`Update`].
12///
13/// Configure the inner [`ReleaseUpdate`] backend and the throttle window,
14/// then call [`build`](Self::build) to produce a `Box<dyn ReleaseUpdate>`
15/// that skips update checks within the configured window.
16#[derive(Default)]
17pub struct UpdateBuilder {
18    release_update: Option<Box<dyn ReleaseUpdate>>,
19    throttle_window: Option<Duration>,
20}
21
22impl UpdateBuilder {
23    /// Initialize a new builder.
24    pub fn new() -> Self {
25        Default::default()
26    }
27
28    /// Set the release update implementation to wrap. Required.
29    pub fn release_update(&mut self, release_update: Box<dyn ReleaseUpdate>) -> &mut Self {
30        self.release_update = Some(release_update);
31        self
32    }
33
34    /// Set the minimum time between update checks. Defaults to 15 minutes.
35    pub fn throttle_window(&mut self, window: Duration) -> &mut Self {
36        self.throttle_window = Some(window);
37        self
38    }
39
40    /// Confirm config and create a ready-to-use `Update`.
41    ///
42    /// * Errors:
43    ///     * Config - `release_update` was not provided
44    pub fn build(&mut self) -> Result<Box<dyn ReleaseUpdate>> {
45        let inner = self
46            .release_update
47            .take()
48            .ok_or_else(|| Error::Config("`release_update` required".to_owned()))?;
49
50        Ok(Box::new(Update {
51            inner,
52            throttle_window: self.throttle_window.unwrap_or(DEFAULT_THROTTLE_WINDOW),
53        }))
54    }
55}
56
57/// Wraps a [`ReleaseUpdate`] and throttles how often update checks run.
58///
59/// The underlying update check is skipped when one was already performed
60/// within `throttle_window`. The time of the last check is tracked via a
61/// throttle file in the system temp directory.
62///
63/// # Throttle behavior
64///
65/// The throttle file is touched only after a *successful* update check.
66/// Failed checks do not reset the throttle window, allowing retries without
67/// waiting for the full window to elapse.
68pub struct Update {
69    inner: Box<dyn ReleaseUpdate>,
70    throttle_window: Duration,
71}
72
73impl Update {
74    /// Initialize a new `Update` builder.
75    pub fn configure() -> UpdateBuilder {
76        UpdateBuilder::new()
77    }
78
79    /// Returns true if enough time has elapsed to perform an update check.
80    fn should_check(&self) -> bool {
81        let path = self.throttle_path();
82        match std::fs::metadata(&path) {
83            Ok(meta) => {
84                if let Ok(mtime) = meta.modified()
85                    && let Ok(elapsed) = std::time::SystemTime::now().duration_since(mtime)
86                {
87                    return elapsed > self.throttle_window;
88                }
89                true
90            }
91            Err(_) => true,
92        }
93    }
94
95    /// Path to the throttle file in the system temp directory.
96    fn throttle_path(&self) -> std::path::PathBuf {
97        std::env::temp_dir().join(format!("{}.throttle", &self.inner.bin_name()))
98    }
99
100    /// Update the throttle file's mtime, creating it if necessary.
101    fn touch_throttle(&self) {
102        let _ = std::fs::OpenOptions::new()
103            .create(true)
104            .truncate(true)
105            .write(true)
106            .open(self.throttle_path());
107    }
108}
109
110impl ReleaseUpdate for Update {
111    fn get_latest_release(&self) -> Result<Release> {
112        self.inner.get_latest_release()
113    }
114
115    fn get_latest_releases(&self, current_version: &str) -> Result<Vec<Release>> {
116        self.inner.get_latest_releases(current_version)
117    }
118
119    fn get_release_version(&self, ver: &str) -> Result<Release> {
120        self.inner.get_release_version(ver)
121    }
122
123    fn current_version(&self) -> String {
124        self.inner.current_version()
125    }
126
127    fn target(&self) -> String {
128        self.inner.target()
129    }
130
131    fn target_version(&self) -> Option<String> {
132        self.inner.target_version()
133    }
134
135    fn bin_name(&self) -> String {
136        self.inner.bin_name()
137    }
138
139    fn bin_install_path(&self) -> std::path::PathBuf {
140        self.inner.bin_install_path()
141    }
142
143    fn bin_path_in_archive(&self) -> String {
144        self.inner.bin_path_in_archive()
145    }
146
147    fn show_download_progress(&self) -> bool {
148        self.inner.show_download_progress()
149    }
150
151    fn show_output(&self) -> bool {
152        self.inner.show_output()
153    }
154
155    fn no_confirm(&self) -> bool {
156        self.inner.no_confirm()
157    }
158
159    fn progress_template(&self) -> String {
160        self.inner.progress_template()
161    }
162
163    fn progress_chars(&self) -> String {
164        self.inner.progress_chars()
165    }
166
167    fn auth_token(&self) -> Option<String> {
168        self.inner.auth_token()
169    }
170
171    fn update(&self) -> Result<Status> {
172        if !self.should_check() {
173            return Ok(Status::UpToDate(self.current_version()));
174        }
175
176        let result = self.inner.update();
177        self.touch_throttle();
178        result
179    }
180
181    fn update_extended(&self) -> Result<UpdateStatus> {
182        if !self.should_check() {
183            return Ok(UpdateStatus::UpToDate);
184        }
185
186        let result = self.inner.update_extended();
187        self.touch_throttle();
188        result
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::test_support::MockRelease;
196
197    fn remove(path: &std::path::Path) {
198        let _ = std::fs::remove_file(path);
199    }
200
201    /// Build a concrete `Update` for white-box tests of the private throttle
202    /// mechanics. `build()` returns a `Box<dyn ReleaseUpdate>`, which hides
203    /// those methods, so tests construct the type directly.
204    fn concrete(mock: MockRelease, window: Duration) -> Update {
205        Update {
206            inner: Box::new(mock),
207            throttle_window: window,
208        }
209    }
210
211    // Each test uses a distinct bin name, so their throttle files never
212    // collide and the tests can run in parallel.
213
214    #[test]
215    fn should_check_is_true_without_a_throttle_file() {
216        let updater = concrete(
217            MockRelease::new("mock-throttle-missing"),
218            DEFAULT_THROTTLE_WINDOW,
219        );
220        remove(&updater.throttle_path());
221
222        assert!(updater.should_check());
223    }
224
225    #[test]
226    fn should_check_is_false_with_a_recent_throttle_file() {
227        let updater = concrete(
228            MockRelease::new("mock-throttle-recent"),
229            DEFAULT_THROTTLE_WINDOW,
230        );
231        let path = updater.throttle_path();
232        remove(&path);
233
234        updater.touch_throttle();
235
236        assert!(!updater.should_check());
237        remove(&path);
238    }
239
240    #[test]
241    fn should_check_is_true_with_an_expired_throttle_file() {
242        let updater = concrete(
243            MockRelease::new("mock-throttle-expired"),
244            Duration::from_secs(60),
245        );
246        let path = updater.throttle_path();
247        std::fs::write(&path, "").unwrap();
248
249        let expired = std::time::SystemTime::now()
250            .duration_since(std::time::UNIX_EPOCH)
251            .unwrap()
252            - Duration::from_secs(120);
253        let expired = filetime::FileTime::from_unix_time(expired.as_secs() as i64, 0);
254        filetime::set_file_mtime(&path, expired).unwrap();
255
256        assert!(updater.should_check());
257        remove(&path);
258    }
259
260    #[test]
261    fn touch_throttle_creates_the_throttle_file() {
262        let updater = concrete(
263            MockRelease::new("mock-throttle-touch"),
264            DEFAULT_THROTTLE_WINDOW,
265        );
266        let path = updater.throttle_path();
267        remove(&path);
268
269        updater.touch_throttle();
270
271        assert!(path.exists());
272        remove(&path);
273    }
274
275    #[test]
276    fn update_runs_inner_and_touches_throttle_when_allowed() {
277        let mock = MockRelease::new("mock-throttle-run");
278        let calls = mock.call_counter();
279        let updater = concrete(mock, DEFAULT_THROTTLE_WINDOW);
280        let path = updater.throttle_path();
281        remove(&path);
282
283        let status = updater.update().unwrap();
284
285        assert!(matches!(status, Status::UpToDate(_)));
286        assert_eq!(calls.get(), 1);
287        assert!(
288            path.exists(),
289            "throttle file should be touched after a check"
290        );
291        remove(&path);
292    }
293
294    #[test]
295    fn update_skips_inner_when_throttled() {
296        let mock = MockRelease::new("mock-throttle-skip");
297        let calls = mock.call_counter();
298        let updater = concrete(mock, DEFAULT_THROTTLE_WINDOW);
299        let path = updater.throttle_path();
300        remove(&path);
301        updater.touch_throttle();
302
303        let status = updater.update().unwrap();
304
305        assert!(matches!(status, Status::UpToDate(_)));
306        assert_eq!(calls.get(), 0);
307        remove(&path);
308    }
309
310    #[test]
311    fn update_extended_runs_inner_when_allowed() {
312        let mock = MockRelease::new("mock-throttle-run-ext");
313        let calls = mock.call_counter();
314        let updater = concrete(mock, DEFAULT_THROTTLE_WINDOW);
315        let path = updater.throttle_path();
316        remove(&path);
317
318        let status = updater.update_extended().unwrap();
319
320        assert!(matches!(status, UpdateStatus::UpToDate));
321        assert_eq!(calls.get(), 1);
322        assert!(path.exists());
323        remove(&path);
324    }
325
326    #[test]
327    fn update_extended_skips_inner_when_throttled() {
328        let mock = MockRelease::new("mock-throttle-skip-ext");
329        let calls = mock.call_counter();
330        let updater = concrete(mock, DEFAULT_THROTTLE_WINDOW);
331        let path = updater.throttle_path();
332        remove(&path);
333        updater.touch_throttle();
334
335        let status = updater.update_extended().unwrap();
336
337        assert!(matches!(status, UpdateStatus::UpToDate));
338        assert_eq!(calls.get(), 0);
339        remove(&path);
340    }
341
342    #[test]
343    fn build_requires_a_release_update() {
344        assert!(Update::configure().build().is_err());
345    }
346
347    #[test]
348    fn builder_wraps_the_release_update() {
349        let mock = MockRelease::new("mock-throttle-builder");
350        let calls = mock.call_counter();
351        let path = std::env::temp_dir().join("mock-throttle-builder.throttle");
352        remove(&path);
353
354        let updater = Update::configure()
355            .release_update(Box::new(mock))
356            .throttle_window(Duration::from_secs(60))
357            .build()
358            .unwrap();
359
360        updater.update().unwrap();
361
362        assert_eq!(calls.get(), 1);
363        remove(&path);
364    }
365}