From e87b593f46aa933232f33094ca4c31a6e8a74131 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 11 Oct 2026 18:47:11 +0200 Subject: [PATCH] fix(media-proxy): fail slow external fetches sooner (#3403) --- fluxer_media_proxy/src/http_client/mod.rs | 50 ++++++++++++++++- fluxer_media_proxy/src/http_client/retry.rs | 62 +++++++++++++++++++-- fluxer_media_proxy/src/server/state.rs | 2 +- 3 files changed, 107 insertions(+), 7 deletions(-) diff --git a/fluxer_media_proxy/src/http_client/mod.rs b/fluxer_media_proxy/src/http_client/mod.rs index b3a04a7d4..55a8ebc2c 100644 --- a/fluxer_media_proxy/src/http_client/mod.rs +++ b/fluxer_media_proxy/src/http_client/mod.rs @@ -14,10 +14,15 @@ use std::time::Duration; pub type HttpClient = ClientWithMiddleware; +const EXTERNAL_CONNECT_TIMEOUT_MS: u64 = 3_000; +const EXTERNAL_READ_TIMEOUT_MS: u64 = 10_000; +const EXTERNAL_RETRY_WINDOW_MS: u64 = 15_000; + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct HTTPClientOptions { connect_timeout_ms: NonZeroU64, request_timeout_ms: NonZeroU64, + read_timeout_ms: Option, retries: HTTPRetryPolicy, address_policy: HTTPAddressPolicy, } @@ -27,6 +32,7 @@ struct HTTPRetryPolicy { max_retries: NonZeroU32, min_delay_ms: NonZeroU64, max_delay_ms: NonZeroU64, + window_ms: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -44,6 +50,18 @@ impl HTTPClientOptions { } } + pub fn external(request_timeout_ms: NonZeroU64) -> Self { + let mut options = Self::new( + NonZeroU64::new(EXTERNAL_CONNECT_TIMEOUT_MS) + .expect("external connection timeout must be nonzero"), + request_timeout_ms, + ) + .restrict_to_public(); + options.read_timeout_ms = NonZeroU64::new(EXTERNAL_READ_TIMEOUT_MS); + options.retries.window_ms = NonZeroU64::new(EXTERNAL_RETRY_WINDOW_MS); + options + } + pub fn restrict_to_public(mut self) -> Self { self.address_policy = HTTPAddressPolicy::PublicOnly; self @@ -57,6 +75,7 @@ impl Default for HTTPClientOptions { .expect("default connection timeout must be nonzero"), request_timeout_ms: NonZeroU64::new(30_000) .expect("default request timeout must be nonzero"), + read_timeout_ms: None, retries: HTTPRetryPolicy { max_retries: NonZeroU32::new(2) .expect("default maximum retry count must be nonzero"), @@ -64,6 +83,7 @@ impl Default for HTTPClientOptions { .expect("default minimum retry delay must be nonzero"), max_delay_ms: NonZeroU64::new(500) .expect("default maximum retry delay must be nonzero"), + window_ms: None, }, address_policy: HTTPAddressPolicy::Any, } @@ -80,6 +100,9 @@ pub fn build_raw(options: HTTPClientOptions) -> Result, metrics: Arc, } impl ObservableRetryPolicy { - pub(super) fn new(inner: ExponentialBackoff, metrics: Arc) -> Self { - Self { inner, metrics } + pub(super) fn new( + inner: ExponentialBackoff, + retry_window: Option, + metrics: Arc, + ) -> Self { + Self { + inner, + retry_window, + metrics, + } } } impl RetryPolicy for ObservableRetryPolicy { fn should_retry(&self, request_start_time: SystemTime, n_past_retries: u32) -> RetryDecision { - let decision = self.inner.should_retry(request_start_time, n_past_retries); + let window_spent = self.retry_window.is_some_and(|window| { + request_start_time + .elapsed() + .is_ok_and(|elapsed| elapsed >= window) + }); + let decision = if window_spent { + RetryDecision::DoNotRetry + } else { + self.inner.should_retry(request_start_time, n_past_retries) + }; match decision { RetryDecision::Retry { .. } => self.metrics.record_retry(), RetryDecision::DoNotRetry => self.metrics.record_retries_exhausted(), @@ -263,7 +281,7 @@ mod tests { let backoff = ExponentialBackoff::builder() .retry_bounds(Duration::from_millis(1), Duration::from_millis(2)) .build_with_max_retries(1); - let policy = ObservableRetryPolicy::new(backoff, metrics.http_client()); + let policy = ObservableRetryPolicy::new(backoff, None, metrics.http_client()); let started = SystemTime::now(); assert!(matches!( policy.should_retry(started, 0), @@ -329,4 +347,38 @@ mod tests { Some(Retryable::Transient) )); } + + fn policy(window: Option) -> ObservableRetryPolicy { + ObservableRetryPolicy::new( + ExponentialBackoff::builder().build_with_max_retries(2), + window, + Arc::new(HTTPClientMetrics::new()), + ) + } + + #[test] + fn a_request_past_its_retry_window_is_not_retried() { + let started = SystemTime::now() - Duration::from_secs(20); + assert!(matches!( + policy(Some(Duration::from_secs(15))).should_retry(started, 0), + RetryDecision::DoNotRetry + )); + } + + #[test] + fn a_request_inside_its_retry_window_is_retried() { + assert!(matches!( + policy(Some(Duration::from_secs(15))).should_retry(SystemTime::now(), 0), + RetryDecision::Retry { .. } + )); + } + + #[test] + fn a_policy_without_a_window_keeps_retrying_slow_requests() { + let started = SystemTime::now() - Duration::from_secs(60); + assert!(matches!( + policy(None).should_retry(started, 0), + RetryDecision::Retry { .. } + )); + } } diff --git a/fluxer_media_proxy/src/server/state.rs b/fluxer_media_proxy/src/server/state.rs index 87d7e638c..df9075774 100644 --- a/fluxer_media_proxy/src/server/state.rs +++ b/fluxer_media_proxy/src/server/state.rs @@ -42,7 +42,7 @@ impl AppState { NonZeroU64::new(cfg.socket_io_timeout_ms).unwrap_or(NonZeroU64::MIN); let store = Store::try_new(cfg.clone(), metrics.storage(), metrics.http_client())?; let external_client = http_client::build( - HTTPClientOptions::new(socket_io_timeout, socket_io_timeout).restrict_to_public(), + HTTPClientOptions::external(socket_io_timeout), metrics.http_client(), )?; let nsfw = NSFWClient::new(