//! Errors the subtitle crate can produce. use std::{path::PathBuf, time::Duration}; use crate::model::{CandidateId, ProviderId, SubtitleFormat}; /// Result alias for every fallible operation in this crate. pub type Result = std::result::Result; /// Everything that can go wrong reaching a subtitle provider. /// /// Every variant names the provider, because two are configured at once /// (DESIGN.md §15) and "search failed" without a name is unactionable in the /// operator message §9.5 folds these into. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum Error { /// The provider could not be reached at all, or the connection died. #[error("{provider} is unreachable")] Transport { /// The provider that was called. provider: ProviderId, /// The underlying transport failure. #[source] source: Box, }, /// The provider refused the credentials it was given. /// /// Separate from [`Error::Transport`] because it never resolves by /// retrying: the configuration is wrong (DESIGN.md §10). #[error("{provider} rejected the configured credentials")] Unauthorized { /// The provider that was called. provider: ProviderId, }, /// The provider's rate limit or daily download cap was hit. /// /// Its own variant so the reconcile loop can show it as a queue state /// rather than a failure — being at the cap is expected (DESIGN.md §15). #[error("{provider} is at its cap")] RateLimited { /// The provider that was called. provider: ProviderId, /// How long the provider asked to be left alone, when it says. retry_after: Option, }, /// A candidate the provider offered is no longer downloadable. #[error("{provider} no longer has candidate {candidate}")] NotFound { /// The provider that was called. provider: ProviderId, /// The candidate that was asked for. candidate: CandidateId, }, /// The provider answered, but not with the shape its API documents. #[error("{provider} answered with something unexpected: {detail}")] Malformed { /// The provider that was called. provider: ProviderId, /// What was wrong, short enough to log. detail: String, }, /// Reading or writing a file on the way to or from a provider failed. #[error("could not read {path}")] Io { /// The file involved. path: PathBuf, /// The underlying IO failure. #[source] source: std::io::Error, }, /// A downloaded subtitle's bytes did not decode under any plausible /// encoding. /// /// Writing mojibake into the library is worse than a failed attempt /// (#186 already records a failure reason). #[error("{candidate} did not decode to UTF-8: {detail}")] Decode { /// The candidate whose bytes failed to decode. candidate: CandidateId, /// What was wrong, short enough to log. detail: String, }, /// A downloaded subtitle could not be converted to SRT (DESIGN.md §15): /// its format is [`SubtitleFormat::Other`], or its own parser rejected /// the text. #[error("{candidate} could not be converted from {format} to SRT: {detail}")] Convert { /// The candidate whose bytes failed to convert. candidate: CandidateId, /// The format conversion was attempted from. format: SubtitleFormat, /// What was wrong, short enough to log. detail: String, }, /// The provider could not be constructed: a bad base URL, or its HTTP /// client failed to build. Always a caller bug — a hardcoded URL or a /// test override — never a fact about the live service. #[error("{provider} could not be configured: {detail}")] Config { /// The provider being constructed. provider: ProviderId, /// What went wrong, short enough to log. detail: String, }, /// `alass` could not be started at all — the binary is not installed /// where the configuration points (#194). A health condition (#200), /// folded into the operator message §9.5 defines; never a panic. #[error("could not run {binary}")] Spawn { /// The binary that was attempted. binary: String, /// The underlying IO failure. #[source] source: std::io::Error, }, /// One `alass` run took longer than its timeout. #[error("syncing {path} timed out")] Timeout { /// The subtitle being synced. path: PathBuf, }, /// `alass` ran and exited non-zero. /// /// Its own variant rather than [`Error::Io`]: the process answered, so /// this is about the sync, and `status` plus a trimmed stderr is what an /// operator needs to see. #[error("syncing {path} failed (status {status:?}): {stderr}")] SyncFailed { /// The subtitle being synced. path: PathBuf, /// The exit code, when it exited rather than died on a signal. status: Option, /// The first part of what it wrote to stderr. stderr: String, }, /// A file that should be SRT — the subtitle going into `alass`, or what /// came out of it — does not parse as SRT. /// /// An implausible output is *not* here: that is [`crate::sync::Outcome::Rejected`], /// a decision, while a file nothing can parse is a fact worth failing on. #[error("{path} is not SRT: {detail}")] NotSrt { /// The file involved. path: PathBuf, /// What was wrong with it, short enough to log. detail: String, }, } impl Error { /// Whether calling the same provider again later is worth doing. /// /// A cap lifts and a network recovers; bad credentials and a malformed /// response do not fix themselves, and retrying them only burns budget. #[must_use] pub const fn is_transient(&self) -> bool { matches!( self, Self::Transport { .. } | Self::RateLimited { .. } | Self::Timeout { .. } ) } } #[cfg(test)] mod tests { use super::Error; use crate::model::ProviderId; #[test] fn a_cap_is_worth_retrying_and_a_bad_key_is_not() { let provider = ProviderId::new("opensubtitles"); assert!(Error::RateLimited { provider: provider.clone(), retry_after: None, } .is_transient()); assert!(!Error::Unauthorized { provider }.is_transient()); } #[test] fn a_timeout_is_worth_retrying_and_a_missing_binary_is_not() { let timeout = Error::Timeout { path: std::path::PathBuf::from("/m/a.srt"), }; let spawn = Error::Spawn { binary: "alass".to_owned(), source: std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"), }; let not_srt = Error::NotSrt { path: std::path::PathBuf::from("/m/a.srt"), detail: "line 1: expected a timing line".to_owned(), }; assert!(timeout.is_transient()); // A missing binary needs installing, and a broken subtitle does not // heal; neither resolves by retrying. assert!(!spawn.is_transient()); assert!(!not_srt.is_transient()); } }