A YouTube feed's spurious 404 or 500 is asked again (#382)

YouTube answers some requests for a channel's feed with a 404 or 500
in tens of milliseconds, while the next request for the same feed, or
another channel's 200 ms later, reads. Checked once a day, the 39
YouTube channels in the catalogue missed most days: 178 failures in
the week to 2026-11-04, against 74 in the ten days before, and all 39
read from Tower when asked by hand.

feed::fetch now asks a youtube.com/feeds/videos.xml address again, up
to twice, a second apart, when it answers 404 or 5xx. Other hosts'
404s are real and are not retried. tokio moves from ipx-feed's dev
dependencies to its own, for the sleep; both services build it already.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 16:46:14 +00:00
parent 72e4a97f87
commit 245fa79fbf
4 changed files with 40 additions and 4 deletions

View File

@@ -41,6 +41,19 @@ pub static FETCHED_BYTES: AtomicU64 = AtomicU64::new(0);
/// catalogue was 62 MB, and 50 MB here stopped it updating (#357).
pub(crate) const MAX_BODY: usize = 200 << 20;
/// How many times a YouTube feed's spurious failure is asked again, and how long between.
const YOUTUBE_RETRIES: u32 = 2;
const YOUTUBE_RETRY_AFTER: Duration = Duration::from_secs(1);
/// YouTube answers some feed requests with a 404 or 500 it does not mean, in tens of
/// milliseconds, while the next request for the same feed reads; checked once a day, a channel
/// then missed most days (#382).
fn youtube_spurious(url: &reqwest::Url, status: StatusCode) -> bool {
url.host_str() == Some("www.youtube.com")
&& url.path() == "/feeds/videos.xml"
&& (status == StatusCode::NOT_FOUND || status.is_server_error())
}
/// A response's body, up to `MAX_BODY`: past it, an error, and the rest is never read.
pub(crate) async fn read_capped(mut resp: reqwest::Response) -> Result<Vec<u8>> {
if resp.content_length().is_some_and(|n| n > MAX_BODY as u64) {
@@ -68,7 +81,9 @@ pub async fn fetch(
let start = reqwest::Url::parse(feed_url).context("the feed's address")?;
let mut url = start.clone();
let mut permanent = true;
for _ in 0..10 {
let mut retries = YOUTUBE_RETRIES;
// Ten redirects, and the retries beside them.
for _ in 0..10 + YOUTUBE_RETRIES {
let mut req = client.get(url.clone()).timeout(FEED_TIMEOUT);
if let Some(tag) = etag {
req = req.header(IF_NONE_MATCH, tag);
@@ -100,6 +115,11 @@ pub async fn fetch(
if status == StatusCode::NOT_MODIFIED {
return Ok((Fetched::NotModified, moved));
}
if retries > 0 && youtube_spurious(&url, status) {
retries -= 1;
tokio::time::sleep(YOUTUBE_RETRY_AFTER).await;
continue;
}
if !status.is_success() {
// WordPress.com answers some readers' feed requests with a browser check that only
// JavaScript passes, for hours at a time and whatever the User-Agent; its API has the
@@ -167,6 +187,19 @@ mod tests {
format!("http://{addr}")
}
#[test]
fn only_a_youtube_feeds_404_or_5xx_is_asked_again() {
use reqwest::StatusCode;
let yt = reqwest::Url::parse("https://www.youtube.com/feeds/videos.xml?user=x").unwrap();
let other = reqwest::Url::parse("https://example.com/feeds/videos.xml").unwrap();
let page = reqwest::Url::parse("https://www.youtube.com/watch?v=x").unwrap();
assert!(super::youtube_spurious(&yt, StatusCode::NOT_FOUND));
assert!(super::youtube_spurious(&yt, StatusCode::INTERNAL_SERVER_ERROR));
assert!(!super::youtube_spurious(&yt, StatusCode::FORBIDDEN));
assert!(!super::youtube_spurious(&other, StatusCode::NOT_FOUND));
assert!(!super::youtube_spurious(&page, StatusCode::NOT_FOUND));
}
#[tokio::test]
async fn a_feed_that_moved_for_good_says_where_and_one_moved_for_now_does_not() {
let base = redirecting_server().await;