ipx-feed's lib.rs splits into leaf modules (#356)
lib.rs held 2,000 lines: fetching, parsing, OPML, Patreon, discovery, artwork, categories, Jev and redaction, and their tests. It is now the module list and the re-exports, so ipx and the directory call feed::fetch, feed::parse and the rest as before: fetch.rs fetch and its limits parse.rs RSS and Atom outlines.rs OPML (not opml.rs, which would shadow the opml crate) patreon.rs a creator's shows discover.rs finding a feed wordpress.rs WordPress.com's API failure.rs failures in words privacy.rs redact, looks_private artwork.rs https art, icons category.rs Apple's categories jev.rs Jev's category Moved verbatim, private items pub(crate) where a sibling calls them, calls across modules module::function. Every definition and doc comment line of the old lib.rs is in exactly one of the new files; each test went with what it tests, and all 40 run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
145
crates/feed/src/failure.rs
Normal file
145
crates/feed/src/failure.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
//! 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<String>,
|
||||
}
|
||||
|
||||
/// 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<u16>) {
|
||||
let low = msg.to_ascii_lowercase();
|
||||
let code = low.split("http ").skip(1).find_map(|r| r.get(..3)?.parse::<u16>().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<Failure> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user