fix(pixiv): keep a frame-zip download hiccup retryable

download_media_to_file's non-transport errors all folded into PixivError::Api, and Api is permanent in pixiv_error_is_retryable — so one 429/5xx, stalled transfer or temp-file write failure while fetching the ugoira frame zip permanently failed the whole post, while the bot's own upload downloads classify the very same classes as retryable (classify_download_error). A new PixivError::Transient carries those classes into the existing retry policy (and into startup validation's 'stays enabled' branch); Http keeps its arm and everything else stays permanent. The retryable table and the startup-validation loop pin both halves.
This commit is contained in:
2026-09-24 02:50:22 +08:00
parent 38e65a3791
commit e6800fd27b
2 changed files with 24 additions and 3 deletions
+13
View File
@@ -39,6 +39,11 @@ pub enum PixivError {
Status(u16),
#[error("pixiv api error: {0}")]
Api(String),
/// A bad moment while preparing media: a transient download status
/// (429 / 5xx), a stalled transfer or a temp-file write failure. A retry
/// can change the answer, so the pixiv retry policy re-fetches these.
#[error("transient pixiv error: {0}")]
Transient(String),
}
/// Native pixiv app-API client.
@@ -228,6 +233,14 @@ impl PixivAPI {
.await
.map_err(|e| match e {
FetchError::Http(e) => PixivError::Http(e),
// A bad moment (429/5xx, a stalled transfer, a temp-file
// write failure) must stay retryable: folding it into Api
// made one hiccup permanently fail the whole ugoira post,
// while the bot's own upload downloads retry the same
// classes.
transient @ (FetchError::Transient(_) | FetchError::Io(_)) => {
PixivError::Transient(format!("frame zip download failed: {transient}"))
}
other => PixivError::Api(format!("frame zip download failed: {other}")),
})?;
let frame_delays = metadata.frames.iter().map(|f| f.delay).collect::<Vec<_>>();
+11 -3
View File
@@ -102,7 +102,7 @@ pub fn is_retryable(err: &FetchError) -> bool {
/// rejected credential is not.
fn pixiv_error_is_retryable(err: &PixivError) -> bool {
match err {
PixivError::Http(_) => true,
PixivError::Http(_) | PixivError::Transient(_) => true,
PixivError::Status(code) if *code == 429 || *code >= 500 => true,
PixivError::Status(_) | PixivError::Api(_) | PixivError::Json(_) | PixivError::NoAuth => {
false
@@ -443,7 +443,11 @@ mod tests {
// it calls `disable()`, a process-wide flag with no reset, so a test
// touching it would order-couple every other pixiv test (the predicate
// it keys on is covered by the table below).
for err in [PixivError::Status(429), PixivError::Status(503)] {
for err in [
PixivError::Status(429),
PixivError::Status(503),
PixivError::Transient("frame zip download failed: transient".into()),
] {
let enabled_before = api::enabled();
let message = startup_validation(Err(err)).unwrap_err();
assert!(message.contains("stays enabled"), "{message}");
@@ -458,11 +462,15 @@ mod tests {
#[test]
fn is_retryable_classifies_transient_and_permanent() {
// Transient: network errors, explicit transient, pixiv 429/5xx.
// Transient: network errors, explicit transient, pixiv 429/5xx, and a
// failed media download (the frame zip's own bad moment).
assert!(is_retryable(&FetchError::Transient("429".into())));
assert!(is_retryable(&FetchError::Pixiv(PixivError::Status(429))));
assert!(is_retryable(&FetchError::Pixiv(PixivError::Status(500))));
assert!(is_retryable(&FetchError::Pixiv(PixivError::Status(503))));
assert!(is_retryable(&FetchError::Pixiv(PixivError::Transient(
"frame zip download failed: transient: media status 429".into()
))));
// Permanent: pixiv 4xx (bad/expired token, forbidden, not found),
// api/auth errors, unparseable bodies, not-found/blocked/sensitive.
assert!(!is_retryable(&FetchError::Pixiv(PixivError::Status(400))));