//! A failed read, in words for whoever subscribes, and as a kind for the log. /// A stored `last_error`, translated into plain words for whoever subscribes: whose problem /// it is, and whether there is a new address to switch to. pub struct Failure { /// What to tell them, in a sentence. pub reason: &'static str, /// Where the feed went, when the error says. pub new_url: Option, } /// A failure's kind, for the log's `error.type` (#91): the HTTP status where there is one, as /// OpenTelemetry names an HTTP error, and otherwise a word for what went wrong. Matches the /// same wording as `explain_failure`; a message it does not know is "other", never a wrong kind. #[must_use] pub fn failure_kind(msg: &str) -> (String, Option) { let low = msg.to_ascii_lowercase(); let code = low.split("http ").skip(1).find_map(|r| r.get(..3)?.parse::().ok()); if let Some(c) = code.filter(|c| (100..600).contains(c)) { return (c.to_string(), Some(c)); } let kind = if low.contains("dns error") || low.contains("failed to lookup address") || low.contains("no address associated") { "dns" } else if low.contains("too many redirects") { "redirect_loop" } else if low.contains("timed out") || low.contains("timeout") { "timeout" } else if low.contains("certificate") || low.contains("tls") { "tls" } else if low.contains("got a web page") { "not_a_feed" } else if low.contains("the site sent ") { "site_message" } else if low.contains("connect") { "connect" } else if low.contains("pars") { "parse" } else { "other" }; (kind.into(), None) } /// Reads a `last_error` the same way `set_feed_error` received it (`format!("{e:#}")` on the /// anyhow chain from `fetch` or `parse`) and says what it means, for the errors worth telling /// someone about. Everything else -- a timeout, a 5xx, a 429, a feed that is simply garbled -- /// comes back `None`: transient by nature, or with nothing more useful to say than the raw /// text already shown once a feed is open. /// /// ponytail: matches on the fixed strings this crate itself produces (`anyhow!("HTTP /// {status}")`, and `parse`'s "got a web page" and "the site sent") plus the substrings a DNS failure /// reliably contains. Fragile if reqwest's own wording changes; the fallback is just showing /// nothing extra, so a miss costs a clearer message, not a wrong one. pub fn explain_failure(msg: &str) -> Option { if let Some(rest) = msg.strip_prefix("got a web page, not a feed") { let new_url = rest.strip_prefix("; it links ").and_then(|r| r.strip_suffix(" as its feed")).map(str::to_owned); return Some(Failure { reason: "The feed moved; this address now shows a web page.", new_url }); } if msg.contains("the site sent ") { return Some(Failure { reason: "The site sent a message instead of the feed; the publisher has to fix it.", new_url: None, }); } let low = msg.to_ascii_lowercase(); if low.contains("http 404") { return Some(Failure { reason: "The publisher took this feed down, or moved it.", new_url: None }); } if low.contains("http 401") || low.contains("http 403") { return Some(Failure { reason: "The site refuses ipx's requests.", new_url: None }); } if low.contains("answered with nothing") { return Some(Failure { reason: "This address answers with nothing; the site may be gone.", new_url: None }); } if low.contains("http 402") { return Some(Failure { reason: "The feed now needs a paid plan.", new_url: None }); } // Only a name that does not exist. A lookup that failed for the moment says nothing about the // site, and NPR's and Megaphone's feeds were labelled gone after a DNS outage (#143). if !is_temporary_lookup_failure(msg) && (low.contains("dns error") || low.contains("failed to lookup address") || low.contains("no address associated")) { return Some(Failure { reason: "This address no longer resolves; the site may be gone.", new_url: None }); } None } /// A host that could not be looked up for the moment -- the resolver's EAI_AGAIN, glibc's /// "Temporary failure in name resolution", musl's "Try again" -- as against a name that does /// not exist ("Name or service not known"). It says the network was not answering, not that the /// feed is gone (#143). #[must_use] pub fn is_temporary_lookup_failure(msg: &str) -> bool { let low = msg.to_ascii_lowercase(); low.contains("dns error") && (low.contains("temporary failure in name resolution") || low.contains("try again")) } #[cfg(test)] mod tests { use super::*; #[test] fn explain_failure_translates_the_errors_the_ui_should_flag() { assert_eq!( explain_failure("HTTP 404 Not Found").unwrap().reason, "The publisher took this feed down, or moved it." ); assert_eq!(explain_failure("HTTP 401 Unauthorized").unwrap().reason, "The site refuses ipx's requests."); assert_eq!(explain_failure("HTTP 403 Forbidden").unwrap().reason, "The site refuses ipx's requests."); assert_eq!(explain_failure("HTTP 402 Payment Required").unwrap().reason, "The feed now needs a paid plan."); assert_eq!( explain_failure("the feed's address answered with nothing at all").unwrap().reason, "This address answers with nothing; the site may be gone." ); let dns = explain_failure("connecting: dns error: failed to lookup address information: Name or service not known") .unwrap(); assert_eq!(dns.reason, "This address no longer resolves; the site may be gone."); // A lookup that failed for the moment is not a site gone (#143). let blip = "connecting: dns error: failed to lookup address information: Temporary failure in name resolution"; assert!(is_temporary_lookup_failure(blip)); assert!(explain_failure(blip).is_none()); assert!(!is_temporary_lookup_failure( "connecting: dns error: failed to lookup address information: Name or service not known" )); let moved = explain_failure("got a web page, not a feed; it links https://x/feed as its feed").unwrap(); assert_eq!(moved.new_url.as_deref(), Some("https://x/feed")); assert!(explain_failure("got a web page, not a feed").unwrap().new_url.is_none()); let down = explain_failure("the site sent \"Unable to establish a DB connection\" instead of a feed").unwrap(); assert_eq!(down.reason, "The site sent a message instead of the feed; the publisher has to fix it."); for transient in [ "HTTP 500 Internal Server Error", "HTTP 429 Too Many Requests", "operation timed out", "not RSS (reached end of input without finding a complete channel) and not Atom (unexpected end of input)", ] { assert!(explain_failure(transient).is_none(), "{transient} must not be flagged"); } } }