Files
arr/crates/arr-subs/src/error.rs
T
2026-08-25 01:04:43 +01:00

213 lines
7.3 KiB
Rust

//! 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<T> = std::result::Result<T, Error>;
/// 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<dyn std::error::Error + Send + Sync>,
},
/// 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<Duration>,
},
/// 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<i32>,
/// 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());
}
}