site: classify HTTP status codes, make transient failures retryable

Twitter (syndication + auth GraphQL) mapped every non-2xx to NotFound,
killing retries on 429/5xx; bsky never checked status; pixiv network
errors arrived wrapped in PixivError and were excluded from the retry
loop. New FetchError::Transient covers 429/5xx from all sites, the
retry loop now also retries Pixiv errors, and 404/410 stay permanent.
This commit is contained in:
2026-08-08 20:04:28 +08:00
parent d61dba5096
commit 9f28af4e6b
5 changed files with 46 additions and 12 deletions
@@ -189,6 +189,14 @@ pub async fn fetch(handle: &str, rkey: &str) -> Result<Post, FetchError> {
]) ])
.send() .send()
.await?; .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?; let text = response.text().await?;
Ok(Post::from_json(&text, rkey.to_string())?) Ok(Post::from_json(&text, rkey.to_string())?)
} }
+11 -7
View File
@@ -150,6 +150,8 @@ pub enum FetchError {
Sensitive, Sensitive,
/// A download exceeded the caller's size cap (see [`download_media_limited`]). /// A download exceeded the caller's size cap (see [`download_media_limited`]).
TooLarge, TooLarge,
/// A transient server-side failure (429 / 5xx); [`fetch`] retries these.
Transient(String),
} }
impl fmt::Display for FetchError { impl fmt::Display for FetchError {
@@ -162,6 +164,7 @@ impl fmt::Display for FetchError {
FetchError::Blocked => write!(f, "blocked"), FetchError::Blocked => write!(f, "blocked"),
FetchError::Sensitive => write!(f, "content withheld (sensitive)"), FetchError::Sensitive => write!(f, "content withheld (sensitive)"),
FetchError::TooLarge => write!(f, "media too large"), 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::Pixiv(e) => Some(e),
FetchError::NotFound | FetchError::Blocked | FetchError::Sensitive => None, FetchError::NotFound | FetchError::Blocked | FetchError::Sensitive => None,
FetchError::TooLarge => 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). /// matches (unsupported links are silently ignored by the bot).
/// ///
/// Transient network failures are retried: 3 total attempts with 1s then 2s /// 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<Option<Fetched>, FetchError> { pub async fn fetch(url: &str) -> Result<Option<Fetched>, FetchError> {
let mut last_http_error = None;
for attempt in 0..3u32 { for attempt in 0..3u32 {
match fetch_once(url).await { match fetch_once(url).await {
Ok(Some(fetched)) => { Ok(Some(fetched)) => {
@@ -256,18 +261,17 @@ pub async fn fetch(url: &str) -> Result<Option<Fetched>, FetchError> {
return Ok(Some(fetched)); return Ok(Some(fetched));
} }
Ok(None) => return Ok(None), Ok(None) => return Ok(None),
Err(FetchError::Http(e)) => { Err(e @ (FetchError::Http(_) | FetchError::Transient(_) | FetchError::Pixiv(_))) => {
last_http_error = Some(e);
if attempt < 2 { if attempt < 2 {
tokio::time::sleep(Duration::from_secs(1 << attempt)).await; tokio::time::sleep(Duration::from_secs(1 << attempt)).await;
} else {
return Err(e);
} }
} }
Err(other) => return Err(other), Err(other) => return Err(other),
} }
} }
Err(FetchError::Http( unreachable!("retry loop always returns")
last_http_error.expect("retry loop always ran 3 attempts"),
))
} }
async fn fetch_once(url: &str) -> Result<Option<Fetched>, FetchError> { async fn fetch_once(url: &str) -> Result<Option<Fetched>, FetchError> {
+12
View File
@@ -136,6 +136,12 @@ impl PixivAPI {
.bearer_auth(access_token) .bearer_auth(access_token)
.send() .send()
.await?; .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?)?; let json: serde_json::Value = serde_json::from_str(&response.text().await?)?;
if json.get("error").is_some() { if json.get("error").is_some() {
let message = json let message = json
@@ -186,6 +192,12 @@ impl PixivAPI {
.bearer_auth(access_token) .bearer_auth(access_token)
.send() .send()
.await?; .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?)?; let json: serde_json::Value = serde_json::from_str(&response.text().await?)?;
if json.get("error").is_some() { if json.get("error").is_some() {
let message = json let message = json
+8 -3
View File
@@ -126,9 +126,14 @@ pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
.header("referer", "https://x.com/") .header("referer", "https://x.com/")
.send() .send()
.await?; .await?;
if !response.status().is_success() { // 404/410 = gone (permanent); 429/5xx = transient and retried by fetch.
log::warn!("twitter auth fetch {id}: HTTP {}", response.status()); let status = response.status();
return Err(FetchError::NotFound); 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 text = response.text().await?;
let json: Value = serde_json::from_str(&text)?; let json: Value = serde_json::from_str(&text)?;
+7 -2
View File
@@ -68,8 +68,13 @@ pub async fn fetch(id: &str) -> Result<Tweet, FetchError> {
)) ))
.send() .send()
.await?; .await?;
if !response.status().is_success() { // 404/410 = gone (permanent); 429/5xx = transient and retried by fetch.
return Err(FetchError::NotFound); 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?; let text = response.text().await?;
// Deleted tweets answer with {"errors": [...]} instead of a tweet. // Deleted tweets answer with {"errors": [...]} instead of a tweet.