From 8b3b2a246b69daafe22e565a181649d7537befd1 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Fri, 14 Aug 2026 20:23:14 +0800 Subject: [PATCH] refactor(errors): derive FetchError and PixivError with thiserror --- AGENTS.md | 2 +- Cargo.lock | 1 + crates/x-media/Cargo.toml | 1 + crates/x-media/src/site/mod.rs | 71 +++++++--------------------- crates/x-media/src/site/pixiv/api.rs | 47 ++++-------------- 5 files changed, 28 insertions(+), 94 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f15cd25..2fb92a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ Docker: `docker build -t tgxmb .` then `docker run --rm -d --name tgxmb --env-fi ## Code Conventions & Common Patterns -- **No anyhow/thiserror.** Errors are hand-rolled enums with manual `Display`/`source()`/`From` impls: `QueueError` (`Retryable { delay_seconds, payload }` / `Permanent`), `SendError` (Retryable/Permanent), `FetchError` (`Http`/`Json`/`Pixiv`/`Site`/`NotFound`/`Blocked`), `PixivError`, `Classification`. New errors should follow this pattern. +- **Errors via `thiserror` derive** (no anyhow): the public, stringified errors — `FetchError` (`Http`/`Json`/`Pixiv`/`Site`/`NotFound`/`Blocked`) and `PixivError` — derive `thiserror::Error` with `#[from]` conversions; `Display`/`source()` come from the derive. The internal control-flow enums — `QueueError` (`Retryable { delay_seconds, payload }` / `Permanent`), `SendError` (Retryable/Permanent), `Classification`, `FallbackError` — carry no `Display` and are handled by direct variant matching. New errors should follow the same split: stringified/public errors derive `thiserror`, internal flow enums stay plain. - **Global state via `std::sync::LazyLock` statics**, not DI: `CONFIG`, `CHAT_STORE`, `TASK_QUEUE` in `handlers.rs`; shared reqwest `CLIENT` in `x-media/src/site/mod.rs`. `Bot` is passed/cloned into handlers; queue workers share the process-wide `send::BOT` (`LazyLock`, force-initialized in `main` so a missing token fails at startup). - **Async**: tokio multi-thread runtime (`#[tokio::main]` default). All rusqlite I/O inside `tokio::task::spawn_blocking`. Long loops use `tokio::select!` with `tokio::sync::{watch, Notify}` stop/wake channels. No streams. - **Blocking sync primitives**: `parking_lot::Mutex` for hot caches, `tokio::sync::Mutex` for async-shared state (pixiv token cache), `AtomicBool` for feature gates. diff --git a/Cargo.lock b/Cargo.lock index 1ed7b95..2427a0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2937,6 +2937,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "thiserror", "tokio", "url", "zip", diff --git a/crates/x-media/Cargo.toml b/crates/x-media/Cargo.toml index c15acf9..2829441 100644 --- a/crates/x-media/Cargo.toml +++ b/crates/x-media/Cargo.toml @@ -13,6 +13,7 @@ url = "2.5.2" bytes = "1" zip = "2" tempfile = "3" +thiserror = "2" rand = "0.8" log = "0.4" tokio = { version = "1.40", features = ["time"] } diff --git a/crates/x-media/src/site/mod.rs b/crates/x-media/src/site/mod.rs index 05aa157..e0aa42c 100644 --- a/crates/x-media/src/site/mod.rs +++ b/crates/x-media/src/site/mod.rs @@ -4,7 +4,6 @@ //! `PATTERN`, `enabled()` and `fetch_from_url()`; a future site plugs in by //! adding one guarded entry in [`fetch_once`]. -use std::fmt; use std::future::Future; use std::pin::Pin; use std::sync::LazyLock; @@ -12,6 +11,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use regex::Regex; +use thiserror::Error; pub mod bsky; pub mod pixiv; @@ -183,84 +183,45 @@ pub fn site_id_from_key(key: &str) -> &'static str { .unwrap_or("unknown") } -#[derive(Debug)] +#[derive(Debug, Error)] pub enum FetchError { - Http(reqwest::Error), - Json(serde_json::Error), - Pixiv(PixivError), + #[error("http error: {0}")] + Http(#[from] reqwest::Error), + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + #[error("pixiv error: {0}")] + Pixiv(#[from] PixivError), /// A site-specific error from a site that keeps its own error type. /// Permanent by default (sites that need retryable site errors convert /// them to [`FetchError::Http`] / [`FetchError::Transient`] before /// returning). Pixiv predates this and keeps the dedicated /// [`FetchError::Pixiv`] variant. + #[error("{site} error: {error}")] Site { site: &'static str, + #[source] error: Box, }, + #[error("not found")] NotFound, + #[error("blocked")] Blocked, /// The post exists but its content is withheld (twitter NSFW / /// age-restricted tweets come back as an empty `{}` from syndication). + #[error("content withheld (sensitive)")] Sensitive, /// A download exceeded the caller's size cap (see [`download_media_limited`]). + #[error("media too large")] TooLarge, /// A transient server-side failure (429 / 5xx); [`fetch`] retries these. + #[error("transient: {0}")] Transient(String), /// A local I/O failure while streaming a download to disk /// (see [`download_media_to_file`]). + #[error("io error: {0}")] Io(std::io::Error), } -impl fmt::Display for FetchError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - FetchError::Http(e) => write!(f, "http error: {e}"), - FetchError::Json(e) => write!(f, "json error: {e}"), - FetchError::Pixiv(e) => write!(f, "pixiv error: {e}"), - FetchError::Site { site, error } => write!(f, "{site} error: {error}"), - FetchError::NotFound => write!(f, "not found"), - 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}"), - FetchError::Io(e) => write!(f, "io error: {e}"), - } - } -} - -impl std::error::Error for FetchError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - FetchError::Http(e) => Some(e), - FetchError::Json(e) => Some(e), - FetchError::Pixiv(e) => Some(e), - FetchError::Site { error, .. } => Some(error.as_ref()), - FetchError::NotFound | FetchError::Blocked | FetchError::Sensitive => None, - FetchError::TooLarge => None, - FetchError::Transient(_) => None, - FetchError::Io(e) => Some(e), - } - } -} - -impl From for FetchError { - fn from(e: reqwest::Error) -> Self { - FetchError::Http(e) - } -} - -impl From for FetchError { - fn from(e: serde_json::Error) -> Self { - FetchError::Json(e) - } -} - -impl From for FetchError { - fn from(e: PixivError) -> Self { - FetchError::Pixiv(e) - } -} - /// Shared HTTP client (browser User-Agent) for twitter/bsky fetches and /// [`download_media`]. pub(crate) static CLIENT: LazyLock = LazyLock::new(|| { diff --git a/crates/x-media/src/site/pixiv/api.rs b/crates/x-media/src/site/pixiv/api.rs index 256500e..8bc49f0 100644 --- a/crates/x-media/src/site/pixiv/api.rs +++ b/crates/x-media/src/site/pixiv/api.rs @@ -8,11 +8,11 @@ use super::model::{IllustrationModel, TypeModel, UgoiraMetadataModel}; use crate::media::Media; use crate::site::FetchError; use std::env; -use std::fmt; use std::io::Read; use std::sync::LazyLock; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, SystemTime}; +use thiserror::Error; const AUTH_TOKEN_URL: &str = "https://oauth.secure.pixiv.net/auth/token"; const APP_API_URL: &str = "https://app-api.pixiv.net"; @@ -23,53 +23,24 @@ const APP_USER_AGENT: &str = "PixivIOSApp/7.13.3 (iOS 14.6; iPhone13,2)"; /// Token refresh safe margin (seconds). const TOKEN_REFRESH_SAFE_MARGIN: u64 = 300; -#[derive(Debug)] +#[derive(Debug, Error)] pub enum PixivError { /// No refresh token available (PIXIV_REFRESH_TOKEN unset). + #[error("pixiv: no authentication")] NoAuth, - Http(reqwest::Error), - Json(serde_json::Error), + #[error("pixiv http error: {0}")] + Http(#[from] reqwest::Error), + #[error("pixiv json error: {0}")] + Json(#[from] serde_json::Error), /// Non-2xx HTTP status from the app API. The code lets [`crate::site::fetch`] /// retry only transient classes (429 / 5xx) instead of burning attempts on /// permanent 4xx (bad token, forbidden, not found). + #[error("pixiv status {0}")] Status(u16), + #[error("pixiv api error: {0}")] Api(String), } -impl fmt::Display for PixivError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - PixivError::NoAuth => write!(f, "pixiv: no authentication"), - PixivError::Http(e) => write!(f, "pixiv http error: {e}"), - PixivError::Json(e) => write!(f, "pixiv json error: {e}"), - PixivError::Status(code) => write!(f, "pixiv status {code}"), - PixivError::Api(message) => write!(f, "pixiv api error: {message}"), - } - } -} - -impl std::error::Error for PixivError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - PixivError::Http(e) => Some(e), - PixivError::Json(e) => Some(e), - _ => None, - } - } -} - -impl From for PixivError { - fn from(e: reqwest::Error) -> Self { - PixivError::Http(e) - } -} - -impl From for PixivError { - fn from(e: serde_json::Error) -> Self { - PixivError::Json(e) - } -} - /// Native pixiv app-API client. pub struct PixivAPI { refresh_token: String,