use serde::Serialize;
use std::sync::{Arc, Mutex};
use crate::error::AppError;
#[cfg(doc)]
use crate::provider::Provider;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum AttemptOutcome {
Delivered,
NoCaptions,
AsrRefused,
LanguageUnavailable,
Unavailable,
RateLimited,
Captcha,
DomTimeout,
BrowserMissing,
SkippedDegraded,
SkippedDisabled,
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct ProviderAttempt {
pub provider: &'static str,
pub outcome: AttemptOutcome,
#[serde(skip_serializing_if = "Option::is_none")]
pub elapsed_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub http_status: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body_len: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub diagnostic: Option<String>,
}
pub(super) fn attempt_outcome(err: &AppError) -> AttemptOutcome {
match err {
AppError::NoSubtitle(crate::error::NoSubtitleReason::LanguageUnavailable)
| AppError::LanguageUnavailable { .. } => AttemptOutcome::LanguageUnavailable,
AppError::NoSubtitle(_) => AttemptOutcome::NoCaptions,
AppError::CaptionsAsrOnly { .. } => AttemptOutcome::AsrRefused,
AppError::RateLimited { .. } => AttemptOutcome::RateLimited,
AppError::CaptchaChallenge { .. } => AttemptOutcome::Captcha,
AppError::BrowserNotFound(_) => AttemptOutcome::BrowserMissing,
AppError::Timeout(_) => AttemptOutcome::DomTimeout,
_ => AttemptOutcome::Unavailable,
}
}
const MAX_DIAGNOSTIC_CHARS: usize = 200;
tokio::task_local! {
pub(crate) static UPSTREAM_DIAGNOSTIC: Arc<Mutex<Option<String>>>;
}
tokio::task_local! {
pub(crate) static ATTEMPT_LEDGER: Arc<Mutex<Vec<ProviderAttempt>>>;
}
pub(super) fn publish(local: &mut Vec<ProviderAttempt>, attempt: ProviderAttempt) {
let _ = ATTEMPT_LEDGER.try_with(|sink| {
sink.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(attempt.clone());
});
local.push(attempt);
}
pub(crate) fn record_upstream_diagnostic(text: &str) {
let truncated: String = text.chars().take(MAX_DIAGNOSTIC_CHARS).collect();
let _ = UPSTREAM_DIAGNOSTIC.try_with(|slot| {
*slot
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(truncated);
});
}
#[cfg(test)]
mod attempt_outcome_tests {
use super::*;
use crate::error::AppResult;
use crate::error::NoSubtitleReason;
use crate::provider::{Format, Provider, ProviderChain, SubtitleInfo};
use async_trait::async_trait;
use std::time::Duration;
fn wire(outcome: AttemptOutcome) -> String {
serde_json::to_value(outcome)
.expect("an outcome serialises")
.as_str()
.expect("an outcome is a string")
.to_string()
}
#[test]
fn every_outcome_serialises_to_its_published_spelling() {
for (outcome, published) in [
(AttemptOutcome::Delivered, "delivered"),
(AttemptOutcome::NoCaptions, "no_captions"),
(AttemptOutcome::AsrRefused, "asr_refused"),
(AttemptOutcome::LanguageUnavailable, "language_unavailable"),
(AttemptOutcome::Unavailable, "unavailable"),
(AttemptOutcome::RateLimited, "rate_limited"),
(AttemptOutcome::Captcha, "captcha"),
(AttemptOutcome::DomTimeout, "dom_timeout"),
(AttemptOutcome::BrowserMissing, "browser_missing"),
(AttemptOutcome::SkippedDegraded, "skipped_degraded"),
(AttemptOutcome::SkippedDisabled, "skipped_disabled"),
] {
assert_eq!(wire(outcome), published, "{outcome:?}");
}
}
#[test]
fn every_chain_error_maps_to_a_published_outcome() {
let cases: Vec<(AppError, AttemptOutcome)> = vec![
(
AppError::NoSubtitle(NoSubtitleReason::NotPublished),
AttemptOutcome::NoCaptions,
),
(
AppError::NoSubtitle(NoSubtitleReason::NotFound),
AttemptOutcome::NoCaptions,
),
(
AppError::NoSubtitle(NoSubtitleReason::PrivateOrAgeRestricted),
AttemptOutcome::NoCaptions,
),
(
AppError::NoSubtitle(NoSubtitleReason::Gone),
AttemptOutcome::NoCaptions,
),
(
AppError::NoSubtitle(NoSubtitleReason::UnavailableForLegalReasons),
AttemptOutcome::NoCaptions,
),
(
AppError::NoSubtitle(NoSubtitleReason::LanguageUnavailable),
AttemptOutcome::LanguageUnavailable,
),
(
AppError::LanguageUnavailable {
available: vec!["pt-BR".to_string()],
},
AttemptOutcome::LanguageUnavailable,
),
(
AppError::CaptionsAsrOnly {
asr_languages: vec!["pt".to_string()],
},
AttemptOutcome::AsrRefused,
),
(
AppError::ProviderUnavailable {
provider: "provider-decopy",
},
AttemptOutcome::Unavailable,
),
(
AppError::RateLimited {
provider: "provider-noiz",
retry_after_secs: Some(30),
},
AttemptOutcome::RateLimited,
),
(
AppError::CaptchaChallenge {
provider: "provider-decopy",
kind: "cf-turnstile",
},
AttemptOutcome::Captcha,
),
(
AppError::BrowserNotFound("chrome missing".to_string()),
AttemptOutcome::BrowserMissing,
),
(
AppError::Timeout("after 30s".to_string()),
AttemptOutcome::DomTimeout,
),
(
AppError::ProviderProtocolError {
provider: "provider-noiz",
detail: "missing field".to_string(),
},
AttemptOutcome::Unavailable,
),
(
AppError::TimedtextUpstreamError("unexpected EOF".to_string()),
AttemptOutcome::Unavailable,
),
(
AppError::SubtitleTooLarge(60_000_000),
AttemptOutcome::Unavailable,
),
(
AppError::Io(std::io::Error::other("disk gone")),
AttemptOutcome::Unavailable,
),
(
AppError::Internal("invariant".to_string()),
AttemptOutcome::Unavailable,
),
(
AppError::InvalidUsage("srt from a transcript".to_string()),
AttemptOutcome::Unavailable,
),
];
for (err, expected) in cases {
assert_eq!(attempt_outcome(&err), expected, "{err:?}");
}
}
#[test]
fn an_attempt_omits_the_fields_it_did_not_measure() {
let attempt = ProviderAttempt {
provider: "provider-decopy",
outcome: AttemptOutcome::SkippedDegraded,
elapsed_ms: None,
http_status: None,
body_len: None,
diagnostic: None,
};
let value = serde_json::to_value(&attempt).expect("serialises");
let object = value.as_object().expect("an attempt is an object");
assert_eq!(object.len(), 2, "only the required pair survives: {value}");
assert_eq!(object["provider"], serde_json::json!("provider-decopy"));
assert_eq!(object["outcome"], serde_json::json!("skipped_degraded"));
}
#[tokio::test]
async fn recording_a_diagnostic_outside_an_attempt_is_a_no_op() {
record_upstream_diagnostic("the page said no");
}
#[tokio::test]
async fn a_diagnostic_survives_the_attempt_and_is_capped() {
let sink: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let long = "x".repeat(MAX_DIAGNOSTIC_CHARS + 50);
UPSTREAM_DIAGNOSTIC
.scope(Arc::clone(&sink), async {
record_upstream_diagnostic(&long);
})
.await;
let recorded = sink.lock().expect("uncontended").take().expect("recorded");
assert_eq!(recorded.chars().count(), MAX_DIAGNOSTIC_CHARS);
}
#[tokio::test]
async fn the_finished_attempts_outlive_a_cancelled_walk() {
struct Refusing;
#[async_trait]
impl Provider for Refusing {
fn name(&self) -> &'static str {
"mock-refusing"
}
async fn fetch_subtitle(
&self,
_video_id: &str,
_language: &str,
_format: Format,
) -> AppResult<SubtitleInfo> {
Err(AppError::CaptchaChallenge {
provider: "mock-refusing",
kind: "cf-turnstile",
})
}
async fn fetch_content(&self, _info: &SubtitleInfo) -> AppResult<Vec<u8>> {
unreachable!("fetch_subtitle already failed")
}
}
struct Hanging;
#[async_trait]
impl Provider for Hanging {
fn name(&self) -> &'static str {
"mock-hanging"
}
async fn fetch_subtitle(
&self,
_video_id: &str,
_language: &str,
_format: Format,
) -> AppResult<SubtitleInfo> {
std::future::pending().await
}
async fn fetch_content(&self, _info: &SubtitleInfo) -> AppResult<Vec<u8>> {
unreachable!("fetch_subtitle never returns")
}
}
let chain = ProviderChain::with_min_interval(
vec![Box::new(Refusing), Box::new(Hanging)],
Duration::from_millis(1),
);
let sink: Arc<Mutex<Vec<ProviderAttempt>>> = Arc::new(Mutex::new(Vec::new()));
let cancelled = tokio::time::timeout(
Duration::from_millis(250),
chain.fetch_subtitle_traced_into("dQw4w9WgXcQ", "en", Format::Srt, &sink),
)
.await;
assert!(cancelled.is_err(), "the deadline has to fire");
let recovered = sink.lock().expect("uncontended");
assert_eq!(
recovered.len(),
1,
"the finished attempt survives and the one in flight is not invented: {recovered:?}"
);
assert_eq!(recovered[0].provider, "mock-refusing");
assert_eq!(recovered[0].outcome, AttemptOutcome::Captcha);
assert!(
recovered[0].elapsed_ms.is_some(),
"a finished attempt carries its measurement: {recovered:?}"
);
}
#[tokio::test]
async fn no_attempt_is_invented_when_the_deadline_fires_mid_flight() {
struct Hanging;
#[async_trait]
impl Provider for Hanging {
fn name(&self) -> &'static str {
"mock-hanging"
}
async fn fetch_subtitle(
&self,
_video_id: &str,
_language: &str,
_format: Format,
) -> AppResult<SubtitleInfo> {
std::future::pending().await
}
async fn fetch_content(&self, _info: &SubtitleInfo) -> AppResult<Vec<u8>> {
unreachable!("fetch_subtitle never returns")
}
}
let chain =
ProviderChain::with_min_interval(vec![Box::new(Hanging)], Duration::from_millis(1));
let sink: Arc<Mutex<Vec<ProviderAttempt>>> = Arc::new(Mutex::new(Vec::new()));
let cancelled = tokio::time::timeout(
Duration::from_millis(150),
chain.fetch_subtitle_traced_into("dQw4w9WgXcQ", "en", Format::Srt, &sink),
)
.await;
assert!(cancelled.is_err(), "the deadline has to fire");
let recovered = sink.lock().expect("uncontended");
assert!(
recovered.is_empty(),
"an attempt still in flight left no measurement, so it gets no entry: {recovered:?}"
);
}
#[tokio::test]
async fn the_ledger_carries_the_words_and_the_skip() {
struct Refusing;
#[async_trait]
impl Provider for Refusing {
fn name(&self) -> &'static str {
"mock-refusing"
}
async fn fetch_subtitle(
&self,
_video_id: &str,
_language: &str,
_format: Format,
) -> AppResult<SubtitleInfo> {
record_upstream_diagnostic("track list did not render within poll limit");
Err(AppError::CaptchaChallenge {
provider: "mock-refusing",
kind: "cf-turnstile",
})
}
async fn fetch_content(&self, _info: &SubtitleInfo) -> AppResult<Vec<u8>> {
unreachable!("fetch_subtitle already failed")
}
}
let chain = ProviderChain::with_min_interval(
vec![Box::new(Refusing), Box::new(Refusing)],
Duration::from_millis(1),
);
let (result, attempts) = chain
.fetch_subtitle_traced("dQw4w9WgXcQ", "en", Format::Srt)
.await;
assert!(result.is_err(), "both entries refuse");
assert_eq!(attempts.len(), 2, "both entries are recorded: {attempts:?}");
assert_eq!(attempts[0].provider, "mock-refusing");
assert_eq!(attempts[0].outcome, AttemptOutcome::Captcha);
assert_eq!(
attempts[0].diagnostic.as_deref(),
Some("track list did not render within poll limit")
);
assert!(attempts[0].elapsed_ms.is_some());
assert_eq!(attempts[1].outcome, AttemptOutcome::SkippedDegraded);
assert!(
attempts[1].elapsed_ms.is_none(),
"a provider that never ran has no measurement"
);
}
}