Rate-limit calls to the ArtCraft API; back off hard on HTTP 429

Every launcher polls the same server, so a FeedThrottle now gates each request:
- at most one a minute once the first load is over;
- up to ten a minute during the first load, front-loaded, with the gaps doubling after each
  failure (the first load ends at the first answer or after ten requests);
- failures back off 2, 4, 8 … minutes (at most an hour), with jitter;
- HTTP 429 waits at least 15 minutes, doubling per 429 in a row up to six hours, never sooner
  than Retry-After, plus up to 50% jitter so a herd of clients doesn't return in step. The
  backoff is saved to cache/feed-throttle.json, so relaunching doesn't skip it.

A check that would have to wait more than ten seconds goes without the feed this time, and the
saved answer stands in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-10 11:14:38 -04:00
parent b42d1f49ec
commit 182530fbc8
5 changed files with 530 additions and 26 deletions

View File

@ -14,6 +14,7 @@ use artcraft_launcher_core::protocol::{
use artcraft_launcher_core::release::{Asset, Release};
use artcraft_launcher_core::{CraftApp, Installed, PackageKind, SUITE, version};
use crate::feed_throttle::{self, FeedThrottle, Outcome};
use crate::http::{self, Http, Stop};
use crate::install::{self, Job};
use crate::manifest::Manifest;
@ -28,9 +29,12 @@ const FRESH_FOR: i64 = 4 * 60;
const GITHUB_FALLBACK_EVERY: i64 = 60 * 60;
/// Until every app has loaded once, GitHub may be asked a little more eagerly.
const EARLY_GITHUB_EVERY: i64 = 5 * 60;
/// Tries at the feed per check (after the very first poll), and the waits between them.
/// Tries at the feed per check (after the very first poll). The pause between tries is the
/// [`FeedThrottle`]'s: seconds during the first load, so a check retries; a minute or more after
/// that, so a check then tries once and the retries come from the next checks.
const FEED_ATTEMPTS: usize = 3;
const FEED_RETRY_WAITS: [Duration; 2] = [Duration::from_secs(1), Duration::from_secs(3)];
/// The longest a check waits on the [`FeedThrottle`] before carrying on without the feed.
const MAX_THROTTLE_WAIT: i64 = 10;
/// After everything has loaded, the feed counts as seriously failed (and GitHub takes over)
/// only after this many checks in a row without an answer…
const SERIOUS_FEED_FAILURES: u32 = 3;
@ -65,6 +69,8 @@ struct Shared {
feed_failures: Mutex<u32>,
/// When the feed last answered (Unix seconds).
feed_ok_at: Mutex<Option<i64>>,
/// How often ArtCraft's API may be called.
feed_throttle: Mutex<FeedThrottle>,
}
impl Shared {
@ -117,6 +123,7 @@ impl NativeBackend {
loaded: AtomicBool::new(false),
feed_failures: Mutex::new(0),
feed_ok_at: Mutex::new(None),
feed_throttle: Mutex::new(FeedThrottle::load(&paths.feed_throttle(), crate::now())),
last_check: Mutex::new(None),
});
let backend = Self { shared, paths, http, source, platform: native_platform(), self_install: crate::self_update::current() };
@ -496,20 +503,25 @@ fn feed_and_fallback(http: &Http, paths: &Paths, shared: &Shared) -> (Option<Fee
let loaded = shared.loaded.load(Ordering::SeqCst);
let first_time = !loaded && saved.is_none();
let attempts = if first_time { 1 } else { FEED_ATTEMPTS };
let fresh = fetch_feed(http, paths, attempts);
let fetched = fetch_feed(http, paths, &feed_url(), &shared.feed_throttle, attempts);
let now = crate::now();
let failures = {
let (fresh, answered, failures) = {
let mut failures = shared.feed_failures.lock().unwrap_or_else(PoisonError::into_inner);
if fresh.is_some() {
*failures = 0;
*shared.feed_ok_at.lock().unwrap_or_else(PoisonError::into_inner) = Some(now);
} else {
*failures = failures.saturating_add(1);
match fetched {
FeedFetch::Answered(feed) => {
*failures = 0;
*shared.feed_ok_at.lock().unwrap_or_else(PoisonError::into_inner) = Some(now);
(Some(feed), true, 0)
}
FeedFetch::Failed => {
*failures = failures.saturating_add(1);
(None, false, *failures)
}
// Not asked this time: things stand as the last request left them.
FeedFetch::Held => (None, *failures == 0 && saved.is_some(), *failures),
}
*failures
};
let ok_at = *shared.feed_ok_at.lock().unwrap_or_else(PoisonError::into_inner);
let answered = fresh.is_some();
let fallback = decide_fallback(answered, loaded, first_time, failures, ok_at, now);
(fresh.or(saved), fallback)
}
@ -565,28 +577,68 @@ fn feed_repo(feed: Option<&Feed>, app: &CraftApp) -> String {
feed.map_or_else(|| app.repo.to_string(), |f| f.repo_for(app))
}
/// Asks ArtCraft's release feed up to `attempts` times (waiting 1 s, then 3 s, between tries),
/// saving a good answer. `None` when it never answered with something readable.
fn fetch_feed(http: &Http, paths: &Paths, attempts: usize) -> Option<Feed> {
let url = feed_url();
/// What came of asking the feed during one check.
enum FeedFetch {
Answered(Feed),
/// Asked, without a good answer.
Failed,
/// Not asked: the [`FeedThrottle`] said to hold off.
Held,
}
/// Asks ArtCraft's release feed up to `attempts` times, as often as `throttle` allows (waiting at
/// most [`MAX_THROTTLE_WAIT`] for it), saving a good answer.
fn fetch_feed(http: &Http, paths: &Paths, url: &str, throttle: &Mutex<FeedThrottle>, attempts: usize) -> FeedFetch {
let mut asked = false;
for attempt in 0..attempts.max(1) {
if attempt > 0 {
std::thread::sleep(FEED_RETRY_WAITS.get(attempt - 1).copied().unwrap_or(Duration::from_secs(3)));
let wait = throttle.lock().unwrap_or_else(PoisonError::into_inner).wait(crate::now());
if wait > MAX_THROTTLE_WAIT {
if !asked {
log::info!("holding off the ArtCraft API for {wait} s");
}
break;
}
match http.get_feed(&url) {
if wait > 0 {
std::thread::sleep(Duration::from_secs(wait.unsigned_abs()));
}
{
let mut throttle = throttle.lock().unwrap_or_else(PoisonError::into_inner);
let now = crate::now();
if throttle.wait(now) > 0 {
break;
}
throttle.sending(now);
}
asked = true;
let outcome = match http.get_feed(url) {
Ok(body) => match Feed::parse(&body) {
Some(feed) => {
if let Err(e) = crate::paths::write_atomic(&paths.feed_cache(), body.as_bytes()) {
log::warn!("couldn't save the release feed: {e}");
}
return Some(feed);
throttle.lock().unwrap_or_else(PoisonError::into_inner).finished(Outcome::Answered, crate::now(), 0.0);
return FeedFetch::Answered(feed);
}
None => {
log::warn!("the release feed answered with something unreadable (try {})", attempt + 1);
Outcome::Failed { retry_after: None }
}
None => log::warn!("the release feed answered with something unreadable (try {})", attempt + 1),
},
Err(e) => log::info!("release feed unavailable (try {}): {e}", attempt + 1),
Err(e) if e.throttled => {
log::warn!("the ArtCraft API asked us to slow down (HTTP 429); holding off");
Outcome::Throttled { retry_after: e.retry_after }
}
Err(e) => {
log::info!("release feed unavailable (try {}): {}", attempt + 1, e.why);
Outcome::Failed { retry_after: e.retry_after }
}
};
throttle.lock().unwrap_or_else(PoisonError::into_inner).finished(outcome, crate::now(), feed_throttle::jitter());
if matches!(outcome, Outcome::Throttled { .. }) {
break;
}
}
None
if asked { FeedFetch::Failed } else { FeedFetch::Held }
}
/// The feed's address. `ARTCRAFT_LAUNCHER_FEED_URL` points at a staging server or a local copy
@ -755,9 +807,63 @@ mod tests {
loaded: AtomicBool::new(false),
feed_failures: Mutex::new(0),
feed_ok_at: Mutex::new(None),
feed_throttle: Mutex::new(FeedThrottle::new()),
}
}
/// A local server answering every request with `status` (and `Retry-After: 120` on a 429),
/// counting the requests.
fn serve(status: &'static str) -> (String, Arc<AtomicUsize>) {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/v1/craft_apps/release_info", listener.local_addr().unwrap());
let hits = Arc::new(AtomicUsize::new(0));
let counted = hits.clone();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
counted.fetch_add(1, Ordering::SeqCst);
let extra = if status.starts_with("429") { "Retry-After: 120\r\n" } else { "" };
let _ = write!(stream, "HTTP/1.1 {status}\r\n{extra}Content-Length: 0\r\nConnection: close\r\n\r\n");
}
});
(url, hits)
}
#[test]
fn a_429_stops_the_retries_and_holds_off_the_api() {
let (url, hits) = serve("429 Too Many Requests");
let dir = tempfile::tempdir().unwrap();
let paths = Paths::under(dir.path());
let http = Http::new().unwrap();
let throttle = Mutex::new(FeedThrottle::load(&paths.feed_throttle(), crate::now()));
assert!(matches!(fetch_feed(&http, &paths, &url, &throttle, FEED_ATTEMPTS), FeedFetch::Failed));
assert_eq!(hits.load(Ordering::SeqCst), 1);
// The next checks (and a relaunch) don't call it at all.
assert!(matches!(fetch_feed(&http, &paths, &url, &throttle, FEED_ATTEMPTS), FeedFetch::Held));
let relaunched = Mutex::new(FeedThrottle::load(&paths.feed_throttle(), crate::now()));
assert!(matches!(fetch_feed(&http, &paths, &url, &relaunched, FEED_ATTEMPTS), FeedFetch::Held));
assert_eq!(hits.load(Ordering::SeqCst), 1);
}
#[test]
fn a_failing_api_is_retried_quickly_only_during_the_first_load() {
let (url, hits) = serve("503 Service Unavailable");
let dir = tempfile::tempdir().unwrap();
let paths = Paths::under(dir.path());
let http = Http::new().unwrap();
let throttle = Mutex::new(FeedThrottle::new());
// First load: three tries, a couple of seconds apart.
assert!(matches!(fetch_feed(&http, &paths, &url, &throttle, FEED_ATTEMPTS), FeedFetch::Failed));
assert_eq!(hits.load(Ordering::SeqCst), 3);
// Once the server has answered, never more than one a minute.
throttle.lock().unwrap().finished(Outcome::Answered, crate::now(), 0.0);
assert!(matches!(fetch_feed(&http, &paths, &url, &throttle, FEED_ATTEMPTS), FeedFetch::Held));
assert_eq!(hits.load(Ordering::SeqCst), 3);
}
const NOW: i64 = 1_791_500_000;
/// The steady state: everything has loaded and the feed is answering.
const LOADED: Fallback = Fallback { trust_feed: true, github_every: GITHUB_FALLBACK_EVERY };

View File

@ -0,0 +1,378 @@
//! [`FeedThrottle`]: how often the launcher may call ArtCraft's API (the release feed).
//!
//! Every launcher in the world polls the same server, so the limits are strict and favour the
//! server over freshness (the last saved answer always stands in):
//!
//! - **At most one request a minute** once the first load is over.
//! - **During the first load** (until the feed first answers, at most [`WARM_REQUESTS`]
//! requests), up to ten a minute: front-loaded, with the gaps doubling after each failure
//! (2 s, 4 s, 8 s, …), so a struggling server sees each client ease off quickly.
//! - **Failures back off**: after a failed request the next waits 2, 4, 8 … minutes (at most an
//! hour), with jitter.
//! - **HTTP 429 throttles hard**: at least 15 minutes, doubling with each 429 in a row up to six
//! hours, never sooner than the server's `Retry-After`, plus up to 50% jitter so a herd of
//! clients doesn't come back in step. This survives restarts (saved to disk), so relaunching
//! doesn't skip the wait.
use std::collections::VecDeque;
use std::hash::{BuildHasher, Hasher};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// The window the per-minute limits count over (seconds).
const WINDOW: i64 = 60;
/// Requests per [`WINDOW`] once the first load is over.
const STEADY_PER_WINDOW: usize = 1;
/// Requests per [`WINDOW`] during the first load.
const WARM_PER_WINDOW: usize = 10;
/// The first load ends at the first good answer, or after this many requests.
const WARM_REQUESTS: u32 = 10;
/// Gaps between first-load requests after 0, 1, 2 … failures in a row (seconds).
const WARM_GAPS: [i64; 6] = [1, 2, 4, 8, 16, 32];
/// After a failure (once loaded), the wait before the next request: this, doubled per failure in
/// a row…
const FAILURE_BACKOFF: i64 = 2 * 60;
/// …up to this.
const MAX_FAILURE_BACKOFF: i64 = 60 * 60;
/// After an HTTP 429, the wait before the next request: this, doubled per 429 in a row…
const THROTTLE_BACKOFF: i64 = 15 * 60;
/// …up to this.
const MAX_THROTTLE_BACKOFF: i64 = 6 * 60 * 60;
/// A `Retry-After` beyond this is ignored as nonsense (we still wait our own backoff).
const MAX_RETRY_AFTER: i64 = 24 * 60 * 60;
/// What happened to one request, as far as pacing is concerned.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Outcome {
/// A readable answer.
Answered,
/// No answer, an error status or an unreadable body: the server may be struggling.
/// `retry_after` is the server's `Retry-After` (seconds), if it sent one.
Failed { retry_after: Option<i64> },
/// HTTP 429 Too Many Requests.
Throttled { retry_after: Option<i64> },
}
/// The part that outlives the process, so a relaunch keeps backing off.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
struct Saved {
/// No request before this (Unix seconds).
not_before: i64,
/// Failed requests in a row.
failures: u32,
/// HTTP 429s in a row.
throttles: u32,
}
/// The pacing state for ArtCraft's API. Times are Unix seconds, passed in so it can be tested.
#[derive(Debug)]
pub struct FeedThrottle {
saved: Saved,
/// When recent requests were sent (the last [`WINDOW`] seconds' worth).
sent: VecDeque<i64>,
/// Requests sent during the first load.
warm_sent: u32,
/// The first load is over: the feed has answered, or we've used up the first-load requests.
warmed: bool,
/// Where the state is saved, if anywhere.
file: Option<PathBuf>,
}
impl FeedThrottle {
/// Fresh state, not saved anywhere.
pub fn new() -> Self {
Self { saved: Saved::default(), sent: VecDeque::new(), warm_sent: 0, warmed: false, file: None }
}
/// The state saved at `file` by an earlier run (if readable and sane), saved back there as it
/// 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 {
saved.not_before = 0;
}
Self { saved, file: Some(file.to_path_buf()), ..Self::new() }
}
/// Seconds to wait before the next request may be sent (0: now).
pub fn wait(&self, now: i64) -> i64 {
let warming = !self.warmed;
let per_window = if warming { WARM_PER_WINDOW } else { STEADY_PER_WINDOW };
let mut at = self.saved.not_before;
let recent: Vec<i64> = self.sent.iter().copied().filter(|t| now - t < WINDOW).collect();
if recent.len() >= per_window
&& let Some(oldest) = recent.get(recent.len() - per_window)
{
at = at.max(oldest + WINDOW);
}
if warming && let Some(last) = recent.last() {
let gap = WARM_GAPS.get(self.saved.failures as usize).or(WARM_GAPS.last()).copied().unwrap_or(WINDOW);
at = at.max(last + gap);
}
(at - now).max(0)
}
/// Notes that a request is being sent now.
pub fn sending(&mut self, now: i64) {
self.sent.push_back(now);
while self.sent.front().is_some_and(|t| now - t >= WINDOW) {
self.sent.pop_front();
}
if !self.warmed {
self.warm_sent += 1;
if self.warm_sent >= WARM_REQUESTS {
self.warmed = true;
}
}
}
/// Notes how the request went. `jitter` is in `0.0..1.0` (see [`jitter`]).
pub fn finished(&mut self, outcome: Outcome, now: i64, jitter: f64) {
let before = self.saved;
match outcome {
Outcome::Answered => {
self.warmed = true;
self.saved = Saved::default();
}
Outcome::Failed { retry_after } => {
self.saved.failures = self.saved.failures.saturating_add(1);
self.saved.throttles = 0;
// During the first load the doubling gaps in `wait` do the backing off.
let backoff = if self.warmed { doubled(FAILURE_BACKOFF, self.saved.failures, MAX_FAILURE_BACKOFF) } else { 0 };
let backoff = spread(backoff, 0.4 * jitter - 0.2);
self.saved.not_before = now + backoff.max(sane(retry_after));
}
Outcome::Throttled { retry_after } => {
self.warmed = true;
self.saved.throttles = self.saved.throttles.saturating_add(1);
let backoff = doubled(THROTTLE_BACKOFF, self.saved.throttles, MAX_THROTTLE_BACKOFF);
// Only ever later than asked: spreading the herd out, never bringing it back early.
self.saved.not_before = now + spread(backoff.max(sane(retry_after)), 0.5 * jitter);
}
}
if self.saved != before {
self.save();
}
}
/// The server sent HTTP 429 recently and we're still waiting it out.
pub fn throttled(&self, now: i64) -> bool {
self.saved.throttles > 0 && self.saved.not_before > now
}
fn save(&self) {
let Some(file) = &self.file else { return };
let saved = serde_json::to_vec(&self.saved).unwrap_or_default();
if let Err(e) = crate::paths::write_atomic(file, &saved) {
log::warn!("couldn't save the ArtCraft API backoff: {e}");
}
}
}
impl Default for FeedThrottle {
fn default() -> Self {
Self::new()
}
}
/// `base` doubled for each repeat after the first (`times` ≥ 1), at most `max`.
fn doubled(base: i64, times: u32, max: i64) -> i64 {
let doublings = times.saturating_sub(1).min(16);
base.saturating_mul(1 << doublings).min(max)
}
/// `secs` scaled by `1 + by`.
fn spread(secs: i64, by: f64) -> i64 {
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
let scaled = (secs as f64 * (1.0 + by)).round() as i64;
scaled.max(0)
}
/// A `Retry-After` in seconds, ignoring negative or absurd values.
fn sane(retry_after: Option<i64>) -> i64 {
retry_after.filter(|s| (0..=MAX_RETRY_AFTER).contains(s)).unwrap_or(0)
}
/// A random number in `0.0..1.0`, different in every launcher (std's hasher keys are random).
pub fn jitter() -> f64 {
let bits = std::collections::hash_map::RandomState::new().build_hasher().finish();
#[allow(clippy::cast_precision_loss)]
let fraction = (bits >> 11) as f64 / (1u64 << 53) as f64;
fraction
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: i64 = 1_791_500_000;
/// Sends requests as fast as the throttle allows for `secs`, each failing with `outcome`.
/// Returns when they were sent.
fn hammer(t: &mut FeedThrottle, from: i64, secs: i64, outcome: Outcome) -> Vec<i64> {
let mut sent = Vec::new();
let mut now = from;
while now < from + secs {
let wait = t.wait(now);
if wait > 0 {
now += wait;
continue;
}
t.sending(now);
sent.push(now);
t.finished(outcome, now, 0.5);
}
sent.retain(|s| *s < from + secs);
sent
}
fn per_minute_max(sent: &[i64]) -> usize {
sent.iter().map(|s| sent.iter().filter(|t| **t >= *s && **t < s + WINDOW).count()).max().unwrap_or(0)
}
#[test]
fn once_loaded_never_more_than_one_request_a_minute() {
let mut t = FeedThrottle::new();
t.sending(NOW);
t.finished(Outcome::Answered, NOW, 0.5);
let sent = hammer(&mut t, NOW, 3600, Outcome::Answered);
assert_eq!(per_minute_max(&sent), 1);
assert_eq!(sent.len(), 59);
assert_eq!(sent.first(), Some(&(NOW + 60)));
}
#[test]
fn the_first_load_may_go_faster_but_never_past_ten_a_minute() {
let mut t = FeedThrottle::new();
let sent = hammer(&mut t, NOW, 60, Outcome::Failed { retry_after: None });
assert!(sent.len() > 1 && per_minute_max(&sent) <= 10, "{sent:?}");
// Front-loaded and falling off: the gaps grow with each failure.
let gaps: Vec<i64> = sent.windows(2).map(|w| w[1] - w[0]).collect();
assert!(gaps.windows(2).all(|g| g[1] >= g[0]), "{gaps:?}");
assert_eq!(sent.first(), Some(&NOW));
}
#[test]
fn a_healthy_first_load_retries_quickly() {
// Even with every request succeeding, after the first answer it's one a minute.
let mut t = FeedThrottle::new();
let sent = hammer(&mut t, NOW, 120, Outcome::Answered);
assert_eq!(sent, vec![NOW, NOW + 60]);
}
#[test]
fn the_first_load_ends_after_ten_requests_even_without_an_answer() {
let mut t = FeedThrottle::new();
for i in 0..WARM_REQUESTS {
t.sending(NOW + i64::from(i));
}
// Back to one a minute.
assert!(t.wait(NOW + 10) >= 50);
}
#[test]
fn failures_back_off_after_loading() {
let mut t = FeedThrottle::new();
t.sending(NOW);
t.finished(Outcome::Answered, NOW, 0.5);
let sent = hammer(&mut t, NOW + 60, 6 * 3600, Outcome::Failed { retry_after: None });
let gaps: Vec<i64> = sent.windows(2).map(|w| w[1] - w[0]).collect();
assert_eq!(gaps.first(), Some(&(2 * 60)));
assert!(gaps.windows(2).all(|g| g[1] >= g[0]), "{gaps:?}");
assert_eq!(gaps.last(), Some(&MAX_FAILURE_BACKOFF));
}
#[test]
fn a_good_answer_resets_the_backoff() {
let mut t = FeedThrottle::new();
t.warmed = true;
for i in 0..5 {
t.finished(Outcome::Failed { retry_after: None }, NOW + i, 0.5);
}
t.finished(Outcome::Answered, NOW + 10, 0.5);
assert_eq!(t.wait(NOW + 10), 0);
}
#[test]
fn a_429_throttles_hard_and_harder() {
let mut t = FeedThrottle::new();
t.sending(NOW);
t.finished(Outcome::Throttled { retry_after: None }, NOW, 0.0);
assert_eq!(t.wait(NOW), THROTTLE_BACKOFF);
assert!(t.throttled(NOW));
let sent = hammer(&mut t, NOW, 24 * 3600, Outcome::Throttled { retry_after: None });
let gaps: Vec<i64> = sent.windows(2).map(|w| w[1] - w[0]).collect();
assert!(gaps.windows(2).all(|g| g[1] >= g[0]), "{gaps:?}");
assert!(gaps.iter().all(|g| *g >= THROTTLE_BACKOFF));
assert!(gaps.last().is_some_and(|g| *g >= MAX_THROTTLE_BACKOFF));
}
#[test]
fn a_429_during_the_first_load_ends_the_fast_start() {
let mut t = FeedThrottle::new();
t.sending(NOW);
t.finished(Outcome::Throttled { retry_after: None }, NOW, 0.0);
let sent = hammer(&mut t, NOW + 1, 3600, Outcome::Throttled { retry_after: None });
assert!(sent.iter().all(|s| *s >= NOW + THROTTLE_BACKOFF), "{sent:?}");
}
#[test]
fn retry_after_is_honoured_but_never_shortens_our_wait() {
let mut t = FeedThrottle::new();
t.finished(Outcome::Throttled { retry_after: Some(3 * 3600) }, NOW, 0.0);
assert_eq!(t.wait(NOW), 3 * 3600);
let mut t = FeedThrottle::new();
t.finished(Outcome::Throttled { retry_after: Some(5) }, NOW, 0.0);
assert_eq!(t.wait(NOW), THROTTLE_BACKOFF);
// Nonsense is ignored.
let mut t = FeedThrottle::new();
t.finished(Outcome::Throttled { retry_after: Some(i64::MAX) }, NOW, 0.0);
assert_eq!(t.wait(NOW), THROTTLE_BACKOFF);
// A 503 with Retry-After is honoured too.
let mut t = FeedThrottle::new();
t.finished(Outcome::Failed { retry_after: Some(600) }, NOW, 0.5);
assert_eq!(t.wait(NOW), 600);
}
#[test]
fn jitter_only_ever_delays_a_429() {
for j in [0.0, 0.25, 0.5, 0.99] {
let mut t = FeedThrottle::new();
t.finished(Outcome::Throttled { retry_after: None }, NOW, j);
let wait = t.wait(NOW);
assert!((THROTTLE_BACKOFF..=THROTTLE_BACKOFF * 3 / 2).contains(&wait), "{j}: {wait}");
}
let j = jitter();
assert!((0.0..1.0).contains(&j));
}
#[test]
fn the_backoff_survives_a_restart() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("feed-throttle.json");
let mut t = FeedThrottle::load(&file, NOW);
t.finished(Outcome::Throttled { retry_after: None }, NOW, 0.0);
let again = FeedThrottle::load(&file, NOW + 60);
assert_eq!(again.wait(NOW + 60), THROTTLE_BACKOFF - 60);
assert!(again.throttled(NOW + 60));
// A good answer clears it.
let mut again = again;
again.finished(Outcome::Answered, NOW + THROTTLE_BACKOFF, 0.0);
assert_eq!(FeedThrottle::load(&file, NOW + THROTTLE_BACKOFF).wait(NOW + THROTTLE_BACKOFF), 0);
}
#[test]
fn a_broken_or_absurd_saved_file_means_no_wait() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("feed-throttle.json");
std::fs::write(&file, b"{not json").unwrap();
assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0);
std::fs::write(&file, br#"{"not_before": 99999999999, "throttles": 3}"#).unwrap();
assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0);
std::fs::write(&file, br#"{"unknown": true}"#).unwrap();
assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0);
}
}

View File

@ -39,6 +39,16 @@ pub struct Http {
token: Option<String>,
}
/// Why ArtCraft's release feed couldn't be read.
#[derive(Debug)]
pub struct FeedError {
pub why: String,
/// The server answered HTTP 429 Too Many Requests.
pub throttled: bool,
/// The server's `Retry-After`, in seconds.
pub retry_after: Option<i64>,
}
impl Http {
pub fn new() -> Result<Self, String> {
let roots = os_roots()?;
@ -121,11 +131,15 @@ impl Http {
}
/// ArtCraft's release feed, or why it couldn't be read. Never takes more than ~10 seconds.
pub fn get_feed(&self, url: &str) -> Result<String, String> {
let mut resp = self.feed.get(url).header("Accept", "application/json").call().map_err(|e| format!("couldn't reach {} ({e})", host(url)))?;
pub fn get_feed(&self, url: &str) -> Result<String, FeedError> {
let failed = |why: String| FeedError { why, throttled: false, retry_after: None };
let mut resp =
self.feed.get(url).header("Accept", "application/json").call().map_err(|e| failed(format!("couldn't reach {} ({e})", host(url))))?;
// Seconds only; an HTTP date falls back to our own backoff.
let retry_after = resp.headers().get("retry-after").and_then(|v| v.to_str().ok()).and_then(|r| r.trim().parse::<i64>().ok());
match resp.status().as_u16() {
200 => resp.body_mut().with_config().limit(MAX_SMALL).read_to_string().map_err(|e| format!("unreadable answer ({e})")),
s => Err(format!("{} answered HTTP {s}", host(url))),
200 => resp.body_mut().with_config().limit(MAX_SMALL).read_to_string().map_err(|e| failed(format!("unreadable answer ({e})"))),
s => Err(FeedError { why: format!("{} answered HTTP {s}", host(url)), throttled: s == 429, retry_after }),
}
}

View File

@ -9,6 +9,7 @@
pub mod backend;
pub mod detect;
pub mod feed_throttle;
pub mod http;
pub mod install;
pub mod login;

View File

@ -41,6 +41,11 @@ impl Paths {
self.cache.join("feed.json")
}
/// How long to hold off calling ArtCraft's API (after failures or HTTP 429), across restarts.
pub fn feed_throttle(&self) -> PathBuf {
self.cache.join("feed-throttle.json")
}
pub fn manifest(&self) -> PathBuf {
self.data.join("installed.json")
}