Full implementation of a personalized daily newspaper delivered as an EPUB. Articles are pulled from a local self-hosted Miniflux instance, enriched with comments, summarized and filtered by DeepSeek AI, and then assembled into two EPUB editions: standard and optimized for the Xteink X4 e-ink reader. Both are served by the local self-hosted BookOrbit OPDS server in a separate library. Then the X4 edition is futher converted to XTC format and served over a separate OPDS server hosted by the Rust binary. Runs are tracked in a local SQLite database so runs are idempotent per date. Full documentation of the plan is in docs/plans and setup and install instructions are in the README.md file.
194 lines
5.9 KiB
Rust
194 lines
5.9 KiB
Rust
//! Shared HTTP client and retry policy (implementation notes §4, spec §3 "retry").
|
|
//!
|
|
//! One [`reqwest::Client`] is built at startup and cloned into every stage that
|
|
//! talks to the network (Miniflux, social, extraction, images, world briefing).
|
|
|
|
use std::time::Duration;
|
|
|
|
/// Descriptive UA required by Reddit and polite everywhere else (§3.4, notes §4).
|
|
pub const USER_AGENT: &str = "the-daily-epub/1.0 (personal rss digest; contact tyler@hallada.net)";
|
|
|
|
/// Default per-request timeout (§3.3: 10s).
|
|
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
/// Build the process-wide HTTP client: rustls, gzip, no cookie jar (notes §4).
|
|
pub fn build_client(timeout: Duration) -> reqwest::Result<reqwest::Client> {
|
|
reqwest::Client::builder()
|
|
.user_agent(USER_AGENT)
|
|
.timeout(timeout)
|
|
.connect_timeout(Duration::from_secs(5))
|
|
.gzip(true)
|
|
// No cookie jar: the `cookies` feature is deliberately off (notes §4).
|
|
.build()
|
|
}
|
|
|
|
/// Jittered exponential backoff, max 3 attempts (crate table "retry").
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct RetryPolicy {
|
|
pub max_attempts: u32,
|
|
pub base_delay: Duration,
|
|
pub max_delay: Duration,
|
|
}
|
|
|
|
impl Default for RetryPolicy {
|
|
fn default() -> Self {
|
|
Self {
|
|
max_attempts: 3,
|
|
base_delay: Duration::from_millis(500),
|
|
max_delay: Duration::from_secs(10),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RetryPolicy {
|
|
/// Delay before attempt `attempt` (1-based), with ±25% jitter.
|
|
pub fn delay_for(&self, attempt: u32) -> Duration {
|
|
let exp = self
|
|
.base_delay
|
|
.saturating_mul(2u32.saturating_pow(attempt.saturating_sub(1)));
|
|
let capped = exp.min(self.max_delay);
|
|
let jitter = rand::random_range(0.75f64..1.25f64);
|
|
Duration::from_secs_f64(capped.as_secs_f64() * jitter).min(self.max_delay)
|
|
}
|
|
|
|
/// Run `op` until it succeeds or returns a non-retryable error.
|
|
///
|
|
/// `op` is retried while it yields an error for which `retryable` is true —
|
|
/// network failures and 5xx responses (§3.1).
|
|
pub async fn run<T, E, F, Fut>(
|
|
&self,
|
|
what: &str,
|
|
retryable: impl Fn(&E) -> bool,
|
|
mut op: F,
|
|
) -> Result<T, E>
|
|
where
|
|
F: FnMut() -> Fut,
|
|
Fut: Future<Output = Result<T, E>>,
|
|
E: std::fmt::Display,
|
|
{
|
|
let mut attempt = 1;
|
|
loop {
|
|
match op().await {
|
|
Ok(v) => return Ok(v),
|
|
Err(e) if attempt < self.max_attempts && retryable(&e) => {
|
|
let delay = self.delay_for(attempt);
|
|
tracing::warn!(
|
|
attempt,
|
|
max = self.max_attempts,
|
|
delay_ms = delay.as_millis() as u64,
|
|
"{what} failed, retrying: {e}"
|
|
);
|
|
tokio::time::sleep(delay).await;
|
|
attempt += 1;
|
|
}
|
|
Err(e) => return Err(e),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// True for network-level failures and 5xx/429 responses (§3.1, §3.4).
|
|
pub fn is_retryable(err: &reqwest::Error) -> bool {
|
|
if err.is_timeout() || err.is_connect() || err.is_request() {
|
|
return true;
|
|
}
|
|
match err.status() {
|
|
Some(s) => s.is_server_error() || s == reqwest::StatusCode::TOO_MANY_REQUESTS,
|
|
None => true,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn client_builds() {
|
|
build_client(DEFAULT_TIMEOUT).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn backoff_grows_and_is_capped() {
|
|
let p = RetryPolicy::default();
|
|
for attempt in 1..=3 {
|
|
let d = p.delay_for(attempt);
|
|
assert!(d <= p.max_delay);
|
|
assert!(d >= Duration::from_millis(300));
|
|
}
|
|
// Second attempt doubles the base delay before jitter (1000ms ± 25%).
|
|
assert!(p.delay_for(2) >= Duration::from_millis(750));
|
|
// Overflow-safe and still capped for absurd attempt numbers.
|
|
let huge = p.delay_for(30);
|
|
assert!(huge <= p.max_delay && huge >= Duration::from_secs(7));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn retries_until_success_then_stops() {
|
|
let policy = RetryPolicy {
|
|
max_attempts: 3,
|
|
base_delay: Duration::from_millis(1),
|
|
max_delay: Duration::from_millis(2),
|
|
};
|
|
let mut calls = 0;
|
|
let out: Result<u8, String> = policy
|
|
.run(
|
|
"test",
|
|
|_| true,
|
|
|| {
|
|
calls += 1;
|
|
let n = calls;
|
|
async move {
|
|
if n < 3 {
|
|
Err("boom".to_string())
|
|
} else {
|
|
Ok(7u8)
|
|
}
|
|
}
|
|
},
|
|
)
|
|
.await;
|
|
assert_eq!(out, Ok(7));
|
|
assert_eq!(calls, 3);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gives_up_after_max_attempts() {
|
|
let policy = RetryPolicy {
|
|
max_attempts: 3,
|
|
base_delay: Duration::from_millis(1),
|
|
max_delay: Duration::from_millis(2),
|
|
};
|
|
let mut calls = 0;
|
|
let out: Result<u8, String> = policy
|
|
.run(
|
|
"test",
|
|
|_| true,
|
|
|| {
|
|
calls += 1;
|
|
async { Err("boom".to_string()) }
|
|
},
|
|
)
|
|
.await;
|
|
assert!(out.is_err());
|
|
assert_eq!(calls, 3);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn non_retryable_errors_fail_fast() {
|
|
let policy = RetryPolicy::default();
|
|
let mut calls = 0;
|
|
let out: Result<u8, String> = policy
|
|
.run(
|
|
"test",
|
|
|_| false,
|
|
|| {
|
|
calls += 1;
|
|
async { Err("nope".to_string()) }
|
|
},
|
|
)
|
|
.await;
|
|
assert!(out.is_err());
|
|
assert_eq!(calls, 1);
|
|
}
|
|
}
|