213 lines
7.3 KiB
Rust
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());
|
|
}
|
|
}
|