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>
146 lines
7.0 KiB
Rust
146 lines
7.0 KiB
Rust
//! 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");
|
|
}
|
|
}
|
|
}
|