Try a feed again in minutes when its host could not be looked up for the moment (#143)
205 of the Directory's 208 feeds without a category had never been read: on 2026-10-05 at 03:24 UTC every one failed with "dns error: ... Temporary failure in name resolution" within a minute, during an outage on Tower that also stopped iPX's log reaching Loki until Tower rebooted at 11:56. They are working podcasts, NPR's, Megaphone's, Acast's, which resolve now. A feed nobody subscribes to is read once a day and a failing one backs off up to a day (#99), so each sat failing, unread and so uncategorised (#117) until the next day, and explain_failure called every DNS error "This address no longer resolves; the site is gone.", on its page and since #142 on its Directory page. A forced check of the 208 read 205; each has its items, its description and its own category now. A temporary lookup failure, EAI_AGAIN, is not recorded on the feed at all: no last_error, no error_since to back off from or call it gone by. The daemon notes it in Ctx::retry and the feed is due again in ten minutes, which the scan's due check and the sleep until the next scan both honour; a restart forgets it, which only makes the retry sooner. A name that does not exist is still the feed's error, explained as "may be gone", and the Directory page, like the feed's own, names a cause only once a feed has been failing a day; before that its last check failed. The page also drops "Nobody here subscribes yet", which said nothing worth a line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
58
src/main.rs
58
src/main.rs
@@ -141,9 +141,38 @@ pub struct Ctx {
|
||||
/// stops every feed scan, every HTTP download and every status command behind it.
|
||||
/// A one-shot CLI run keeps them inline, or the process would exit mid-download.
|
||||
pub detach_torrents: bool,
|
||||
/// Feeds whose host could not be looked up for the moment, and when to try them again: a
|
||||
/// failure that says nothing about the feed, kept off it and out of the database (#143).
|
||||
/// Forgotten on a restart, which only makes the retry sooner.
|
||||
pub retry: std::sync::Mutex<std::collections::HashMap<String, i64>>,
|
||||
}
|
||||
|
||||
/// How soon a feed whose host could not be looked up is tried again (#143).
|
||||
const RETRY_LOOKUP_SECS: i64 = 600;
|
||||
|
||||
impl Ctx {
|
||||
/// When a feed is next due, taking a lookup retry into account: not before it.
|
||||
fn due(&self, cfg: &config::Config, feed: &config::Feed, id: &str, state: &db::HttpState, subscribed: bool) -> Option<i64> {
|
||||
let at = due_at(cfg, feed, state, subscribed);
|
||||
match self.retry.lock().unwrap().get(id) {
|
||||
Some(&retry) => Some(at.unwrap_or(0).max(retry)),
|
||||
None => at,
|
||||
}
|
||||
}
|
||||
|
||||
/// A scan's failure, recorded on the feed, unless it was only a moment's failure to look its
|
||||
/// host up: on 2026-10-05 a DNS outage on Tower failed 204 feeds in a minute with
|
||||
/// "Temporary failure in name resolution", and each, read once a day and backed off as a
|
||||
/// failing feed, sat a day failing, unread and labelled gone (#143). Tried again in minutes
|
||||
/// instead, with nothing on the feed to say it failed.
|
||||
async fn feed_failed(&self, id: &str, url: &str, msg: &str) -> Result<()> {
|
||||
if feed::is_temporary_lookup_failure(msg) {
|
||||
self.retry.lock().unwrap().insert(id.to_owned(), db::now() + RETRY_LOOKUP_SECS);
|
||||
return Ok(());
|
||||
}
|
||||
self.db.set_feed_error(id, url, msg).await
|
||||
}
|
||||
|
||||
pub fn cfg(&self) -> std::sync::Arc<config::Config> {
|
||||
self.cfg.read().unwrap().clone()
|
||||
}
|
||||
@@ -275,6 +304,7 @@ async fn main() -> Result<()> {
|
||||
torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)),
|
||||
config_path: config_path.clone(),
|
||||
detach_torrents: is_daemon,
|
||||
retry: Default::default(),
|
||||
});
|
||||
|
||||
let result = match cli.command {
|
||||
@@ -1093,7 +1123,7 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
|
||||
state.last_modified = None;
|
||||
}
|
||||
|
||||
if !force && let Some(at) = due_at(&cfg, &sub.cfg, &state, subscribed.contains_key(id)) {
|
||||
if !force && let Some(at) = ctx.due(&cfg, &sub.cfg, id, &state, subscribed.contains_key(id)) {
|
||||
if at > db::now() {
|
||||
ctx.out.emit(Event::FeedSkip {
|
||||
feed: id.clone(),
|
||||
@@ -1165,7 +1195,7 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
|
||||
// One bad feed must not end the scan.
|
||||
let msg = format!("{e:#}");
|
||||
ctx.out.emit(Event::FeedError { feed: id.clone(), msg: msg.clone() });
|
||||
ctx.db.set_feed_error(id, &feed_cfg.url, &msg).await?;
|
||||
ctx.feed_failed(id, &feed_cfg.url, &msg).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1191,7 +1221,7 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
|
||||
Err(e) => {
|
||||
let msg = format!("{e:#}");
|
||||
ctx.out.emit(Event::FeedError { feed: id.clone(), msg: msg.clone() });
|
||||
ctx.db.set_feed_error(id, &feed_cfg.url, &msg).await?;
|
||||
ctx.feed_failed(id, &feed_cfg.url, &msg).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1329,7 +1359,9 @@ async fn until_next_scan(ctx: &Ctx) -> std::time::Duration {
|
||||
.iter()
|
||||
// A feed with no row yet has never been checked: due now.
|
||||
.map(|s| {
|
||||
states.get(&s.id).and_then(|st| due_at(&cfg, &s.cfg, st, subscribed.contains_key(&s.id))).unwrap_or(0)
|
||||
let never = db::HttpState::default();
|
||||
let state = states.get(&s.id).unwrap_or(&never);
|
||||
ctx.due(&cfg, &s.cfg, &s.id, state, subscribed.contains_key(&s.id)).unwrap_or(0)
|
||||
})
|
||||
.min(),
|
||||
)
|
||||
@@ -2283,9 +2315,27 @@ mod tests {
|
||||
torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)),
|
||||
config_path: PathBuf::new(),
|
||||
detach_torrents: false,
|
||||
retry: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_moments_lookup_failure_is_tried_again_soon_and_not_held_against_the_feed() {
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
let blip = "connecting: dns error: failed to lookup address information: Temporary failure in name resolution";
|
||||
ctx.feed_failed("npr", "https://feeds.npr.org/x", blip).await.unwrap();
|
||||
// Nothing on the feed: no error, no run of failures to back off from or call it gone by.
|
||||
let s = ctx.db.feed_summary("npr").await.unwrap();
|
||||
assert!(s.last_error.is_none() && s.error_since.is_none(), "{:?}", s.last_error);
|
||||
// Due again in minutes, though a feed nobody subscribes to is otherwise read once a day.
|
||||
let state = db::HttpState { last_checked: Some(db::now() - 2 * 86_400), ..Default::default() };
|
||||
let due = ctx.due(&config::Config::default(), &feed(), "npr", &state, false).unwrap();
|
||||
assert!((due - db::now() - RETRY_LOOKUP_SECS).abs() <= 2, "{due}");
|
||||
// A real failure is the feed's.
|
||||
ctx.feed_failed("gone", "https://x/feed", "HTTP 404 Not Found").await.unwrap();
|
||||
assert_eq!(ctx.db.feed_summary("gone").await.unwrap().last_error.as_deref(), Some("HTTP 404 Not Found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reload_takes_the_catalogue_another_ipx_wrote() {
|
||||
let with = |id: &str| {
|
||||
|
||||
Reference in New Issue
Block a user