youtube-legend-cli 0.4.0

Non-interactive Rust CLI that downloads YouTube subtitles through third-party providers, using a native Unix stdin/stdout interface.
//! Exponential-backoff retry helper.

use crate::error::{AppError, AppResult};
use std::time::Duration;
use tokio::time::sleep;

/// Compiled default behind `net.retry.max_attempts`.
const DEFAULT_MAX_ATTEMPTS: u8 = 3;

/// Compiled default behind `net.retry.backoff_base_ms`.
const DEFAULT_BACKOFF_BASE_MS: u64 = 1_000;

/// Compiled default behind `net.retry.backoff_max_ms`.
const DEFAULT_BACKOFF_MAX_MS: u64 = 30_000;

/// Compiled default behind `net.retry.rate_limit_default_secs`.
const DEFAULT_RATE_LIMIT_SECS: u64 = 60;

/// Compiled default behind `net.retry.rate_limit_cap_secs`.
const DEFAULT_RATE_LIMIT_CAP_SECS: u64 = 300;

/// How many attempts a provider call gets before its error is returned.
///
/// Resolves `net.retry.max_attempts`. Exposed so that every call site
/// reads the one key: a second, local reading of the same policy is how
/// the two retryable-error lists of this crate drifted apart once.
#[must_use]
pub fn max_attempts() -> u8 {
    let resolved = crate::config::tuning_u64_in_range(
        "net.retry.max_attempts",
        u64::from(DEFAULT_MAX_ATTEMPTS),
        1,
        10,
    );
    u8::try_from(resolved).unwrap_or(DEFAULT_MAX_ATTEMPTS)
}

/// Back-off applied after the zero-based `attempt` failed.
///
/// Resolves `net.retry.backoff_base_ms`, doubles it by `2^attempt` and
/// clamps the result by `net.retry.backoff_max_ms`.
fn backoff_delay(attempt: u8) -> Duration {
    let base = crate::config::tuning_u64_in_range(
        "net.retry.backoff_base_ms",
        DEFAULT_BACKOFF_BASE_MS,
        1,
        600_000,
    );
    let ceiling = crate::config::tuning_u64_in_range(
        "net.retry.backoff_max_ms",
        DEFAULT_BACKOFF_MAX_MS,
        1,
        3_600_000,
    );
    // A shift wider than the type yields `None`; saturating to `u64::MAX`
    // then clamping keeps a large `max_attempts` on the ceiling instead
    // of panicking or wrapping back to a short delay.
    let factor = 1_u64.checked_shl(u32::from(attempt)).unwrap_or(u64::MAX);
    Duration::from_millis(base.saturating_mul(factor).min(ceiling))
}

/// Seconds to wait on HTTP 429, honouring the upstream `Retry-After`.
///
/// Resolves `net.retry.rate_limit_default_secs` when the upstream sent
/// no header, and clamps the result by `net.retry.rate_limit_cap_secs`.
fn rate_limit_wait_secs(retry_after_secs: Option<u64>) -> u64 {
    let fallback = crate::config::tuning_u64_in_range(
        "net.retry.rate_limit_default_secs",
        DEFAULT_RATE_LIMIT_SECS,
        1,
        86_400,
    );
    let cap = crate::config::tuning_u64_in_range(
        "net.retry.rate_limit_cap_secs",
        DEFAULT_RATE_LIMIT_CAP_SECS,
        1,
        86_400,
    );
    retry_after_secs.unwrap_or(fallback).min(cap)
}

/// Retry `op` up to `max_attempts` times, pausing an exponentially
/// growing back-off between attempts.
///
/// The delay after the zero-based attempt `n` is
/// `net.retry.backoff_base_ms * 2^n`, clamped by
/// `net.retry.backoff_max_ms`. It is computed, never looked up in a
/// fixed table: the previous three-slot table returned no delay at all
/// from the fourth attempt on, so raising `net.retry.max_attempts`
/// above the compiled default hammered the upstream with zero wait.
///
/// Only errors classified as retryable ([`AppError::Timeout`],
/// [`AppError::ProviderUnavailable`], [`AppError::RateLimited`],
/// [`AppError::Http`]) trigger a back-off; any other error is returned
/// immediately. An HTTP 429 ([`AppError::RateLimited`]) waits for the
/// upstream `Retry-After` value instead of the exponential delay,
/// falling back to `net.retry.rate_limit_default_secs` when the header
/// is absent and clamped by `net.retry.rate_limit_cap_secs` (EC-021).
/// The final attempt's failure is returned verbatim, so the caller sees
/// the error the upstream actually produced rather than a generic
/// stand-in.
///
/// Callers take the attempt budget from [`max_attempts`] rather than
/// passing a literal, so the key governs every retry loop in the crate.
///
/// # Cancel safety
///
/// Each call to `op` is its own future; dropping the returned future
/// at any `await` point cancels the in-flight `op` cleanly.
///
/// # Errors
///
/// Returns the last [`AppError`] produced by `op` after exhausting
/// all `max_attempts` retries, with the following variants in scope:
///
/// - [`AppError::ProviderUnavailable`] when every upstream call failed
/// - [`AppError::RateLimited`] when the last upstream call returned 429
///   even after the backoff window
/// - [`AppError::Http`] for transport-level errors that survived retries
/// - [`AppError::Timeout`] when the cumulative wait exceeded the budget
pub async fn retry_with_backoff<F, Fut, T>(mut op: F, max_attempts: u8) -> AppResult<T>
where
    F: FnMut() -> Fut,
    Fut: std::future::Future<Output = AppResult<T>>,
{
    for attempt in 0..max_attempts {
        match op().await {
            Ok(value) => return Ok(value),
            Err(e) if !is_retryable(&e) => return Err(e),
            // Preserve the real error instead of flattening it. Replacing
            // it with a generic `ProviderUnavailable` here is how a precise
            // upstream answer — "this video has no transcript" — reached the
            // caller as "providers unavailable", with the opposite retry
            // semantics attached to it.
            Err(e) if attempt + 1 == max_attempts => return Err(e),
            Err(AppError::RateLimited {
                retry_after_secs, ..
            }) => {
                let wait = rate_limit_wait_secs(retry_after_secs);
                tracing::debug!(
                    target: "events",
                    event = "retry",
                    attempt = attempt + 1,
                    next_delay_secs = wait,
                    "rate limited (HTTP 429); honouring Retry-After"
                );
                sleep(Duration::from_secs(wait)).await;
            }
            Err(_) => {
                let delay = backoff_delay(attempt);
                tracing::debug!(
                    target: "events",
                    event = "retry",
                    attempt = attempt + 1,
                    next_delay_secs = delay.as_secs(),
                    "transient failure; backing off"
                );
                sleep(delay).await;
            }
        }
    }

    // Only reachable when `max_attempts` is zero, i.e. the caller asked
    // for no attempts at all. This is NOT the flattening path that used
    // to discard the real error on the last attempt; that one now
    // returns `e` verbatim above.
    //
    // It reports `Internal` rather than `ProviderUnavailable` because
    // asking for zero attempts is a defect in the caller, not a failure
    // of any provider: no provider was ever contacted, so naming one
    // here would put a provider in the envelope that never ran.
    Err(AppError::Internal(
        "retry_with_backoff was called with max_attempts = 0".to_string(),
    ))
}

/// Whether `err` is worth retrying.
///
/// Delegates to [`AppError::retryable`], which is the single source of
/// truth. Keeping a second list here is exactly how the two drifted
/// apart: a provider could return a well-formed, definitive content
/// answer typed as a transient failure, and this function would retry
/// it — burning the full budget to rediscover the same fact.
fn is_retryable(err: &AppError) -> bool {
    // An offline run refuses every outbound request LOCALLY, before a
    // socket exists, so nothing whatsoever changes between attempt one
    // and attempt three. Retrying a refusal this process issued to
    // itself buys the same answer at the price of the whole backoff
    // budget.
    //
    // MEASURED on 2026-09-01: `--offline` against an uncached video
    // took 9022 ms wall, of which 3002, 3001 and 3002 ms were three
    // providers each sleeping 1000 + 2000 ms between attempts that
    // could not differ. A differential run with `backoff_base_ms = 1`
    // finished the same work in 2023 ms, which is how the sleep was
    // proven to be the whole cost.
    //
    // The test is placed here, and not on the variant, on purpose.
    // `ProviderUnavailable` is raised at roughly fifty sites for
    // genuinely transient upstream conditions, and those must stay
    // retryable; what is not retryable is any error produced while the
    // caller itself has forbidden the network.
    if crate::provider::is_offline() {
        return false;
    }
    err.retryable()
}

// GAP-087: an in-memory `CircuitBreaker` lived here from the first
// release and never acquired a caller — measured on 2026-08-31 as zero
// references outside this file and zero tests of its own. It was
// removed rather than wired, because the one place that would consume
// it is the provider chain, and that chain already carries a live
// failure-counting policy in `crate::net::waf::EscalationPolicy`. A
// second, unrelated counter there would have been the drift, not the
// fix.

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicU8, Ordering};

    #[tokio::test(start_paused = true)]
    async fn rate_limited_waits_retry_after_seconds() {
        let calls = AtomicU8::new(0);
        let start = tokio::time::Instant::now();
        let result = retry_with_backoff(
            || async {
                if calls.fetch_add(1, Ordering::SeqCst) == 0 {
                    Err(AppError::RateLimited {
                        provider: "provider-noiz",
                        retry_after_secs: Some(2),
                    })
                } else {
                    Ok(42u8)
                }
            },
            3,
        )
        .await;
        assert_eq!(result.expect("second attempt succeeds"), 42);
        assert_eq!(calls.load(Ordering::SeqCst), 2);
        let elapsed = start.elapsed();
        assert!(
            elapsed >= Duration::from_secs(2),
            "virtual clock advanced only {elapsed:?}"
        );
    }

    /// A 429 with no `Retry-After` is the upstream declining to name a
    /// horizon, and this asserts that we stop instead of guessing one.
    ///
    /// It used to assert the opposite: that we waited a 60 s fallback
    /// and tried again. MEASURED on 2026-09-01 against the real noiz
    /// endpoint, that fallback is wrong in the case it exists for — a
    /// daily quota answers 429 with no header and clears the NEXT DAY,
    /// so the wait burns a minute of the caller's budget to be told the
    /// same thing. With a header present the upstream has named the
    /// horizon, and `rate_limited_with_header_waits_the_named_delay`
    /// above still covers that path.
    #[tokio::test(start_paused = true)]
    async fn rate_limited_without_header_is_definitive() {
        let calls = AtomicU8::new(0);
        let start = tokio::time::Instant::now();
        let result = retry_with_backoff(
            || async {
                calls.fetch_add(1, Ordering::SeqCst);
                Err::<(), _>(AppError::RateLimited {
                    provider: "provider-noiz",
                    retry_after_secs: None,
                })
            },
            3,
        )
        .await;
        assert!(result.is_err(), "a quota with no horizon must not retry");
        assert_eq!(
            calls.load(Ordering::SeqCst),
            1,
            "the operation was attempted more than once"
        );
        let elapsed = start.elapsed();
        assert!(
            elapsed < Duration::from_secs(1),
            "waited {elapsed:?} for a limit the upstream refused to date"
        );
    }

    #[tokio::test(start_paused = true)]
    async fn rate_limited_wait_is_capped_at_300s() {
        let calls = AtomicU8::new(0);
        let start = tokio::time::Instant::now();
        let result = retry_with_backoff(
            || async {
                if calls.fetch_add(1, Ordering::SeqCst) == 0 {
                    Err(AppError::RateLimited {
                        provider: "provider-noiz",
                        retry_after_secs: Some(9999),
                    })
                } else {
                    Ok(())
                }
            },
            3,
        )
        .await;
        assert!(result.is_ok());
        let elapsed = start.elapsed();
        assert!(
            elapsed >= Duration::from_secs(300) && elapsed < Duration::from_secs(360),
            "expected ~300s of virtual wait, got {elapsed:?}"
        );
    }

    /// With the compiled defaults the waits are 1, 2, 4, 8, 16 and 30
    /// seconds: the sixth would be 32 s and is clamped by
    /// `net.retry.backoff_max_ms`. Summing to 61 s proves both the
    /// doubling and the ceiling, and it proves that attempts past the
    /// third still wait, which the old three-slot table did not do.
    #[tokio::test(start_paused = true)]
    async fn backoff_doubles_and_stops_at_the_ceiling() {
        let calls = AtomicU8::new(0);
        let start = tokio::time::Instant::now();
        let result: AppResult<()> = retry_with_backoff(
            || async {
                calls.fetch_add(1, Ordering::SeqCst);
                Err(AppError::Timeout("forced".to_string()))
            },
            7,
        )
        .await;
        assert!(matches!(result, Err(AppError::Timeout(_))));
        assert_eq!(calls.load(Ordering::SeqCst), 7);
        let elapsed = start.elapsed();
        assert!(
            elapsed >= Duration::from_secs(61) && elapsed < Duration::from_secs(62),
            "expected 1+2+4+8+16+30 s of virtual wait, got {elapsed:?}"
        );
    }
}