From db87bc0f4282dcd8daca57a1dbf2f02e3329713d Mon Sep 17 00:00:00 2001 From: rays Date: Tue, 29 Sep 2026 19:33:57 +0000 Subject: [PATCH] Back off a failing feed exponentially, up to a day (#99) A feed that failed was tried again on its usual schedule however long it had been failing: gizmodo's 404, pelgrane's 403, daily-quests' 503 and toddstashwick's redirect loop every hour, each a request to a site that had said no, a warning and scan time. A failing feed now waits as long as it has been failing, from error_since to its last check, never less than its usual interval and never more than a day: 1h, 1h, 2h, 4h, 8h, 16h, then daily on an hourly schedule. No new column: error_since already marks the run's start and the first success clears it. A forced refresh skips the due check, so it still tries at once. The feed list's next check follows the backoff. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/configuration.md | 4 +++- src/db.rs | 3 +++ src/main.rs | 45 +++++++++++++++++++++++++++++++++++++++---- src/web.rs | 4 ++-- 5 files changed, 50 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6227361..b8f61d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- A feed that keeps failing is checked less and less often, waiting as long as it has been failing, up to once a day; it goes back to its schedule as soon as it works. Refreshing it still checks it at once. - A scan's trace shows the time a feed spends on its artwork and in the database after the fetch. - The JSON log carries each line's `trace_id` and `span_id`, logs each scan event once instead of twice, names a failure's kind in `error.type` (and its HTTP status in diff --git a/docs/configuration.md b/docs/configuration.md index f24e803..79ac544 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -45,7 +45,9 @@ media_types = ["audio", "video"] database as described above; `download_dir`, `socket` and `organize` stay in config.toml. * **`schedule`** — how often feeds are re-checked. A feed's own `` still wins when it asks to - be polled *less* often, and a per-feed `schedule` overrides both. Admin-only from the UI. + be polled *less* often, and a per-feed `schedule` overrides both. A feed that keeps failing + waits as long as it has been failing before the next try, up to a day, and is back on schedule + after its first success. Admin-only from the UI. * **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`. * **`max_total_gb`** — the reaper deletes to get back under this, oldest first, keeping a 50 MB pad. Kept items are never deleted, and a file only counts as read once every subscriber has diff --git a/src/db.rs b/src/db.rs index efb62de..45434e2 100644 --- a/src/db.rs +++ b/src/db.rs @@ -415,6 +415,8 @@ pub struct HttpState { pub last_modified: Option, pub last_checked: Option, pub ttl_mins: Option, + /// When the feed's current run of failures began, for backing off (`due_after`). + pub error_since: Option, } impl Db { @@ -427,6 +429,7 @@ impl Db { last_modified: f.last_modified, last_checked: f.last_checked, ttl_mins: f.ttl_mins.map(|t| t.max(0) as u64), + error_since: f.error_since, }) .unwrap_or_default()) } diff --git a/src/main.rs b/src/main.rs index 53dab98..b6dfa3f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -981,7 +981,7 @@ async fn fetch(ctx: &Arc, only: Option<&str>, force: bool, scope: &[String] } if !force && let Some(last) = state.last_checked { - let due = last + due_after(&cfg, feed_cfg, state.ttl_mins) as i64; + let due = last + due_after(&cfg, feed_cfg, state.ttl_mins, state.error_since, last) as i64; if due > db::now() { ctx.out.emit(Event::FeedSkip { feed: id.clone(), @@ -1165,13 +1165,34 @@ async fn retire_stranded(ctx: &Ctx) -> Result { /// /// A per-feed schedule is an explicit instruction and wins outright. Without one, the /// global schedule applies, but the feed's own raises it when the publisher asks to -/// be polled less often. -pub fn due_after(cfg: &config::Config, feed: &config::Feed, ttl_mins: Option) -> u64 { +/// be polled less often. A feed that is failing backs off (`backoff`), from `error_since`, when +/// its run of failures began, to `last_checked`, when it last failed. +pub fn due_after( + cfg: &config::Config, + feed: &config::Feed, + ttl_mins: Option, + error_since: Option, + last_checked: i64, +) -> u64 { let mins = match feed.schedule.as_deref().and_then(config::parse_interval) { Some(explicit) => explicit, None => ttl_mins.unwrap_or(0).max(cfg.general.interval()), }; - mins * 60 + let failing_for = error_since.map(|since| (last_checked - since).max(0) as u64); + backoff(mins * 60, failing_for) +} + +/// A failing feed waits as long as it has been failing, so the wait doubles with each failure +/// (1h, 1h, 2h, 4h, ... on an hourly schedule), never less than its usual interval and, past that, +/// never more than a day. Retried hourly, a feed dead for good cost a request, a warning and scan +/// time every hour (#99). The first success clears error_since, and with it the backoff; a +/// refresh someone asks for is not held back by it. +fn backoff(usual: u64, failing_for: Option) -> u64 { + const CEILING: u64 = 86_400; + match failing_for { + Some(f) => usual.max(f.min(CEILING)), + None => usual, + } } #[derive(Default)] @@ -1836,6 +1857,22 @@ fn duration(secs: u64) -> String { mod tests { use super::*; + #[test] + fn a_failing_feed_backs_off_doubling_up_to_a_day() { + let hour = 3600; + assert_eq!(backoff(hour, None), hour); + // Failing since 0: each check waits as long as the failure has lasted so far. + let (mut at, mut waits) = (0, vec![]); + for _ in 0..8 { + let w = backoff(hour, Some(at)); + waits.push(w / hour); + at += w; + } + assert_eq!(waits, [1, 1, 2, 4, 8, 16, 24, 24]); + // A weekly schedule is longer than the ceiling and stays as it is. + assert_eq!(backoff(7 * 86_400, Some(30 * 86_400)), 7 * 86_400); + } + #[tokio::test] async fn the_first_start_moves_the_configuration_in_and_trims_the_file() { let dir = std::env::temp_dir().join(format!("ipx-assemble-{}", std::process::id())); diff --git a/src/web.rs b/src/web.rs index ce7e3e2..5fbf7a1 100644 --- a/src/web.rs +++ b/src/web.rs @@ -790,11 +790,11 @@ async fn feeds( .schedule .as_deref() .and_then(crate::config::parse_interval), - every_mins: crate::due_after(&cfg, feed, ttl_mins) / 60, + every_mins: crate::due_after(&cfg, feed, ttl_mins, None, 0) / 60, last_checked: s.last_checked, next_check: s .last_checked - .map(|t| t + crate::due_after(&cfg, feed, ttl_mins) as i64), + .map(|t| t + crate::due_after(&cfg, feed, ttl_mins, s.error_since, t) as i64), failing: s .error_since .filter(|since| crate::db::now() - since >= FLAG_AFTER_SECS)