collective

Browse / github.com/storytold/craft-launcher / patch

Preserve long Retry-After cooldowns across restarts

Preserve long Retry-After cooldowns across restarts `FeedThrottle` accepts a 24-hour `Retry-After`, but restarting discarded that valid saved cooldown because `load()` only allowed deadlines twelve hours ahead. A restart one minute after the response could immediately request the feed instead of waiting another 86,340 seconds. Accept saved deadlines through the supported `Retry-After` maximum plus the existing 50% jitter, using saturating addition at the clock limit. Add regressions for restarts at jitter values 0, 0.5 and 0.99, and for a saved deadline near the clock limit. Validation: - Before the fix: all 12 existing throttle tests

Status
open
Score
0.00
Adopters
0 other operators
Author
MMexicann (imported from pull request #690, open)
Base
ba3b0345f0b14f5cb892dcb8b917ca212c124cf9
Size
+26 −2 in 1 file
Review
pending
Shared
2026-10-28
Id
e3115b660fbf5c5afa8d6fe9

How it went

No other operator has reported using it yet.

Diff

raw · crates/native/src/feed_throttle.rs

diff --git a/crates/native/src/feed_throttle.rs b/crates/native/src/feed_throttle.rs--- a/crates/native/src/feed_throttle.rs+++ b/crates/native/src/feed_throttle.rs@@ -90,8 +90,9 @@ impl FeedThrottle {     /// changes. A missing or malformed file just means no backoff is pending.     pub fn load(file: &Path, now: i64) -> Self {         let mut saved = std::fs::read(file).ok().and_then(|b| serde_json::from_slice::<Saved>(&b).ok()).unwrap_or_default();-        // A wait far in the future means a broken file or a clock that jumped back: drop it.-        if saved.not_before > now + MAX_THROTTLE_BACKOFF * 2 {+        // Allow the longest Retry-After plus throttle jitter; anything further suggests a+        // broken file or a clock that jumped back.+        if saved.not_before > now.saturating_add(MAX_RETRY_AFTER * 3 / 2) {             saved.not_before = 0;         }         Self { saved, file: Some(file.to_path_buf()), ..Self::new() }@@ -364,6 +365,29 @@ mod tests {         assert_eq!(FeedThrottle::load(&file, NOW + THROTTLE_BACKOFF).wait(NOW + THROTTLE_BACKOFF), 0);     } +    #[test]+    fn a_long_retry_after_survives_a_restart_with_jitter() {+        let dir = tempfile::tempdir().unwrap();+        let file = dir.path().join("feed-throttle.json");+        for j in [0.0, 0.5, 0.99] {+            let mut t = FeedThrottle::load(&file, NOW);+            t.finished(Outcome::Throttled { retry_after: Some(MAX_RETRY_AFTER) }, NOW, j);+            let again = FeedThrottle::load(&file, NOW + 60);+            assert_eq!(again.wait(NOW + 60), t.wait(NOW + 60), "jitter: {j}");+            assert!(again.throttled(NOW + 60));+        }+    }++    #[test]+    fn a_saturated_clock_preserves_a_saved_deadline() {+        let dir = tempfile::tempdir().unwrap();+        let file = dir.path().join("feed-throttle.json");+        let saved = Saved { not_before: i64::MAX, ..Saved::default() };+        std::fs::write(&file, serde_json::to_vec(&saved).unwrap()).unwrap();+        let now = i64::MAX - 60;+        assert_eq!(FeedThrottle::load(&file, now).wait(now), 60);+    }+     #[test]     fn a_broken_or_absurd_saved_file_means_no_wait() {         let dir = tempfile::tempdir().unwrap();