diff --git a/crates/x-media/src/site/mod.rs b/crates/x-media/src/site/mod.rs
index 1e327be..d2bd96d 100644
--- a/crates/x-media/src/site/mod.rs
+++ b/crates/x-media/src/site/mod.rs
@@ -299,10 +299,32 @@ pub(crate) fn log_once_ffmpeg_missing() {
/// Fetches a post from its URL. Returns `Ok(None)` when no site pattern
/// matches (unsupported links are silently ignored by the bot).
///
-/// Transient network failures are retried: 3 total attempts with 1s then 2s
-/// 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.
+/// Transient failures are retried: 3 total attempts with 1s then 2s delays.
+/// Retried classes: bare HTTP errors, [`FetchError::Transient`] (429/5xx
+/// from any site), pixiv network errors, and pixiv HTTP statuses that are
+/// actually transient (429 / 5xx). Permanent classes are returned
+/// immediately: Json, NotFound, Blocked, Sensitive, pixiv 4xx statuses
+/// (bad/expired token, forbidden, not found) and pixiv API/auth errors.
+/// Whether [`fetch`] should retry `err` (3 total attempts, 1s then 2s
+/// backoff). Permanent classes — 4xx statuses, invalid tokens, unparseable
+/// bodies, not-found/blocked/sensitive — are returned immediately; retrying
+/// them only wastes attempts against the source site.
+fn fetch_error_is_retryable(err: &FetchError) -> bool {
+ match err {
+ FetchError::Http(_) | FetchError::Transient(_) => true,
+ FetchError::Pixiv(e) => match e {
+ PixivError::Http(_) => true,
+ PixivError::Status(code) if *code == 429 || *code >= 500 => true,
+ // 4xx, invalid token, unparseable body: retrying cannot help.
+ PixivError::Status(_)
+ | PixivError::Api(_)
+ | PixivError::Json(_)
+ | PixivError::NoAuth => false,
+ },
+ _ => false,
+ }
+}
+
pub async fn fetch(url: &str) -> Result