diff --git a/crates/x-media/src/site/bsky/interface.rs b/crates/x-media/src/site/bsky/interface.rs index f49ebe3..791f2f2 100644 --- a/crates/x-media/src/site/bsky/interface.rs +++ b/crates/x-media/src/site/bsky/interface.rs @@ -189,6 +189,14 @@ pub async fn fetch(handle: &str, rkey: &str) -> Result { ]) .send() .await?; + // 404/410 = gone (permanent); 429/5xx = transient and retried by fetch. + let status = response.status(); + if !status.is_success() { + return match status.as_u16() { + 404 | 410 => Err(FetchError::NotFound), + _ => Err(FetchError::Transient(format!("bsky status {status}"))), + }; + } let text = response.text().await?; Ok(Post::from_json(&text, rkey.to_string())?) } diff --git a/crates/x-media/src/site/mod.rs b/crates/x-media/src/site/mod.rs index e60bca0..5dc4cb9 100644 --- a/crates/x-media/src/site/mod.rs +++ b/crates/x-media/src/site/mod.rs @@ -150,6 +150,8 @@ pub enum FetchError { Sensitive, /// A download exceeded the caller's size cap (see [`download_media_limited`]). TooLarge, + /// A transient server-side failure (429 / 5xx); [`fetch`] retries these. + Transient(String), } impl fmt::Display for FetchError { @@ -162,6 +164,7 @@ impl fmt::Display for FetchError { FetchError::Blocked => write!(f, "blocked"), FetchError::Sensitive => write!(f, "content withheld (sensitive)"), FetchError::TooLarge => write!(f, "media too large"), + FetchError::Transient(message) => write!(f, "transient: {message}"), } } } @@ -174,6 +177,7 @@ impl std::error::Error for FetchError { FetchError::Pixiv(e) => Some(e), FetchError::NotFound | FetchError::Blocked | FetchError::Sensitive => None, FetchError::TooLarge => None, + FetchError::Transient(_) => None, } } } @@ -242,9 +246,10 @@ pub(crate) fn log_once_ffmpeg_missing() { /// matches (unsupported links are silently ignored by the bot). /// /// Transient network failures are retried: 3 total attempts with 1s then 2s -/// delays. Non-Http errors (Json/NotFound/Blocked/Pixiv) are not retried. +/// delays. Retried classes: bare HTTP errors, [`FetchError::Transient`] +/// (429/5xx from any site), and pixiv errors (its network failures arrive +/// wrapped as `PixivError`). Non-retried: Json/NotFound/Blocked/Sensitive. pub async fn fetch(url: &str) -> Result, FetchError> { - let mut last_http_error = None; for attempt in 0..3u32 { match fetch_once(url).await { Ok(Some(fetched)) => { @@ -256,18 +261,17 @@ pub async fn fetch(url: &str) -> Result, FetchError> { return Ok(Some(fetched)); } Ok(None) => return Ok(None), - Err(FetchError::Http(e)) => { - last_http_error = Some(e); + Err(e @ (FetchError::Http(_) | FetchError::Transient(_) | FetchError::Pixiv(_))) => { if attempt < 2 { tokio::time::sleep(Duration::from_secs(1 << attempt)).await; + } else { + return Err(e); } } Err(other) => return Err(other), } } - Err(FetchError::Http( - last_http_error.expect("retry loop always ran 3 attempts"), - )) + unreachable!("retry loop always returns") } async fn fetch_once(url: &str) -> Result, FetchError> { diff --git a/crates/x-media/src/site/pixiv/api.rs b/crates/x-media/src/site/pixiv/api.rs index 171938d..a849d94 100644 --- a/crates/x-media/src/site/pixiv/api.rs +++ b/crates/x-media/src/site/pixiv/api.rs @@ -136,6 +136,12 @@ impl PixivAPI { .bearer_auth(access_token) .send() .await?; + if !response.status().is_success() { + return Err(PixivError::Api(format!( + "status {}", + response.status() + ))); + } let json: serde_json::Value = serde_json::from_str(&response.text().await?)?; if json.get("error").is_some() { let message = json @@ -186,6 +192,12 @@ impl PixivAPI { .bearer_auth(access_token) .send() .await?; + if !response.status().is_success() { + return Err(PixivError::Api(format!( + "status {}", + response.status() + ))); + } let json: serde_json::Value = serde_json::from_str(&response.text().await?)?; if json.get("error").is_some() { let message = json diff --git a/crates/x-media/src/site/twitter/auth.rs b/crates/x-media/src/site/twitter/auth.rs index 33f18f0..db6b89c 100644 --- a/crates/x-media/src/site/twitter/auth.rs +++ b/crates/x-media/src/site/twitter/auth.rs @@ -126,9 +126,14 @@ pub async fn fetch(id: &str) -> Result { .header("referer", "https://x.com/") .send() .await?; - if !response.status().is_success() { - log::warn!("twitter auth fetch {id}: HTTP {}", response.status()); - return Err(FetchError::NotFound); + // 404/410 = gone (permanent); 429/5xx = transient and retried by fetch. + let status = response.status(); + if !status.is_success() { + log::warn!("twitter auth fetch {id}: HTTP {status}"); + return match status.as_u16() { + 404 | 410 => Err(FetchError::NotFound), + _ => Err(FetchError::Transient(format!("twitter auth status {status}"))), + }; } let text = response.text().await?; let json: Value = serde_json::from_str(&text)?; diff --git a/crates/x-media/src/site/twitter/interface.rs b/crates/x-media/src/site/twitter/interface.rs index 7c1ff42..70baf87 100644 --- a/crates/x-media/src/site/twitter/interface.rs +++ b/crates/x-media/src/site/twitter/interface.rs @@ -68,8 +68,13 @@ pub async fn fetch(id: &str) -> Result { )) .send() .await?; - if !response.status().is_success() { - return Err(FetchError::NotFound); + // 404/410 = gone (permanent); 429/5xx = transient and retried by fetch. + let status = response.status(); + if !status.is_success() { + return match status.as_u16() { + 404 | 410 => Err(FetchError::NotFound), + _ => Err(FetchError::Transient(format!("twitter status {status}"))), + }; } let text = response.text().await?; // Deleted tweets answer with {"errors": [...]} instead of a tweet.