use std::error::Error as StdError;
use std::fmt::Write as _;
use std::net::IpAddr;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use futures::StreamExt;
use percent_encoding::{CONTROLS, percent_encode};
use reqwest::header::{
CONTENT_TYPE, ETAG, HeaderName, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED, LOCATION,
};
use reqwest::redirect::Policy;
use reqwest::{Response, StatusCode};
use thiserror::Error;
use url::{Host, Url};
use super::config::redact_url;
use super::egress::{AllowAll, EgressDenied, EgressPolicy, PolicyDns};
use super::error::RssError;
use crate::util::http::{clock_jitter_nanos, parse_retry_after};
pub(crate) const MAX_REDIRECT_HOPS: u32 = 5;
pub(crate) const MAX_ATTEMPTS: u32 = 3;
pub(crate) const RETRY_BASE_BACKOFF_MS: u64 = 250;
const MAX_RETRY_WAIT: Duration = Duration::from_secs(10);
const RETRYABLE_STATUSES: [u16; 5] = [429, 500, 502, 503, 504];
#[derive(Debug, Clone, Default)]
pub struct Validators {
pub etag: Option<String>,
pub last_modified: Option<String>,
}
#[derive(Debug)]
pub enum FetchOutcome {
NotModified { http_status: u16 },
Fetched {
body: Vec<u8>,
http_status: u16,
etag: Option<String>,
last_modified: Option<String>,
content_type: Option<String>,
final_url: String,
},
}
#[derive(Debug, Error)]
pub enum FetchError {
#[error("{0}")]
Egress(#[from] EgressDenied),
#[error("response exceeded {limit} bytes")]
TooLarge { limit: u64 },
#[error("request timed out after {seconds}s")]
Timeout { seconds: u64 },
#[error("http status {status}")]
Status { status: u16 },
#[error("too many redirects (limit {hops})")]
TooManyRedirects { hops: u32 },
#[error("invalid feed url: {reason}")]
InvalidUrl { reason: String },
#[error("transport error: {reason}")]
Transport { reason: String },
}
enum HopOutcome {
Done(FetchOutcome),
Redirect(String),
}
#[derive(Debug)]
pub struct FeedFetcher {
http: reqwest::Client,
policy: Arc<dyn EgressPolicy>,
request_timeout: Duration,
max_response_bytes: u64,
}
impl FeedFetcher {
pub fn new(
policy: Option<Arc<dyn EgressPolicy>>,
request_timeout: Duration,
max_response_bytes: u64,
user_agent: String,
) -> Result<Self, RssError> {
let policy_injected = policy.is_some();
let policy: Arc<dyn EgressPolicy> = policy.unwrap_or_else(|| Arc::new(AllowAll));
let resolver = Arc::new(PolicyDns::new(Arc::clone(&policy)));
let mut builder = reqwest::Client::builder()
.dns_resolver(resolver)
.redirect(Policy::none())
.gzip(true)
.timeout(request_timeout)
.user_agent(user_agent);
if policy_injected {
builder = builder.no_proxy();
}
let http = builder.build().map_err(|e| RssError::HttpClientBuild {
reason: e.to_string(),
})?;
Ok(Self {
http,
policy,
request_timeout,
max_response_bytes,
})
}
pub async fn fetch(
&self,
url: &str,
validators: Option<&Validators>,
) -> Result<FetchOutcome, FetchError> {
let mut current = self.parse_and_check(url)?;
let mut redirects_followed: u32 = 0;
loop {
let send_validators = if redirects_followed == 0 {
validators
} else {
None
};
match self.attempt_hop(¤t, send_validators).await? {
HopOutcome::Done(outcome) => return Ok(outcome),
HopOutcome::Redirect(location) => {
if redirects_followed >= MAX_REDIRECT_HOPS {
return Err(FetchError::TooManyRedirects {
hops: MAX_REDIRECT_HOPS,
});
}
current = self.resolve_redirect_target(¤t, &location)?;
redirects_followed += 1;
}
}
}
}
fn parse_and_check(&self, url: &str) -> Result<Url, FetchError> {
let parsed = Url::parse(url).map_err(|e| FetchError::InvalidUrl {
reason: e.to_string(),
})?;
self.check_hop_target(&parsed)?;
Ok(parsed)
}
fn resolve_redirect_target(&self, current: &Url, location: &str) -> Result<Url, FetchError> {
let target = current.join(location).map_err(|e| FetchError::InvalidUrl {
reason: format!(
"redirect location '{location}' does not resolve against '{}': {e}",
redact_url(current)
),
})?;
self.check_hop_target(&target)?;
Ok(target)
}
fn check_hop_target(&self, url: &Url) -> Result<(), FetchError> {
if url.scheme() != "http" && url.scheme() != "https" {
return Err(FetchError::InvalidUrl {
reason: format!("scheme '{}' is not http or https", url.scheme()),
});
}
let ip = match url.host() {
Some(Host::Ipv4(v4)) => Some(IpAddr::V4(v4)),
Some(Host::Ipv6(v6)) => Some(IpAddr::V6(v6)),
_ => None,
};
if let Some(ip) = ip {
let canonical = ip.to_canonical();
let host = url.host_str().unwrap_or_default().to_string();
self.policy
.check_ip(canonical)
.map_err(|reason| EgressDenied {
host,
ip: canonical,
reason,
})?;
}
Ok(())
}
async fn attempt_hop(
&self,
url: &Url,
validators: Option<&Validators>,
) -> Result<HopOutcome, FetchError> {
let mut last_err: Option<FetchError> = None;
for attempt in 0..MAX_ATTEMPTS {
let mut req = self.http.get(url.clone());
let mut sent_validator = false;
if let Some(v) = validators {
if let Some(etag) = &v.etag {
req = req.header(IF_NONE_MATCH, etag);
sent_validator = true;
}
if let Some(last_modified) = &v.last_modified {
req = req.header(IF_MODIFIED_SINCE, last_modified);
sent_validator = true;
}
}
match req.send().await {
Ok(resp) => {
let status = resp.status();
if status.as_u16() == 304 {
if sent_validator {
return Ok(HopOutcome::Done(FetchOutcome::NotModified {
http_status: 304,
}));
}
return Err(FetchError::Status { status: 304 });
}
if status.is_redirection()
&& let Some(location) = resp.headers().get(LOCATION)
{
let location = percent_encode(location.as_bytes(), CONTROLS).to_string();
return Ok(HopOutcome::Redirect(location));
}
if is_retryable_status(status) {
let err = FetchError::Status {
status: status.as_u16(),
};
if attempt + 1 >= MAX_ATTEMPTS {
return Err(err);
}
tokio::time::sleep(retry_wait(&resp, attempt)).await;
last_err = Some(err);
continue;
}
if status == StatusCode::OK {
match self.read_body(resp, url).await {
Ok(outcome) => return Ok(HopOutcome::Done(outcome)),
Err(
err @ (FetchError::Timeout { .. } | FetchError::Transport { .. }),
) => {
if attempt + 1 >= MAX_ATTEMPTS {
return Err(err);
}
tokio::time::sleep(backoff(attempt)).await;
last_err = Some(err);
continue;
}
Err(err) => return Err(err),
}
}
return Err(FetchError::Status {
status: status.as_u16(),
});
}
Err(e) => {
if let Some(denied) = find_egress_denied(&e) {
return Err(FetchError::Egress(denied));
}
let mapped = if e.is_timeout() {
FetchError::Timeout {
seconds: self.request_timeout.as_secs(),
}
} else {
FetchError::Transport {
reason: transport_reason(e),
}
};
if attempt + 1 >= MAX_ATTEMPTS {
return Err(mapped);
}
tokio::time::sleep(backoff(attempt)).await;
last_err = Some(mapped);
}
}
}
Err(last_err.unwrap_or(FetchError::Transport {
reason: "exhausted retry attempts without a recorded error".to_string(),
}))
}
async fn read_body(&self, resp: Response, url: &Url) -> Result<FetchOutcome, FetchError> {
let http_status = resp.status().as_u16();
let etag = header_string(&resp, ETAG);
let last_modified = header_string(&resp, LAST_MODIFIED);
let content_type = header_string(&resp, CONTENT_TYPE);
let limit = self.max_response_bytes;
let mut body: Vec<u8> = Vec::new();
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| {
if e.is_timeout() {
FetchError::Timeout {
seconds: self.request_timeout.as_secs(),
}
} else {
FetchError::Transport {
reason: format!("failed to read response body: {}", transport_reason(e)),
}
}
})?;
if body.len() as u64 + chunk.len() as u64 > limit {
return Err(FetchError::TooLarge { limit });
}
body.extend_from_slice(&chunk);
}
Ok(FetchOutcome::Fetched {
body,
http_status,
etag,
last_modified,
content_type,
final_url: url.to_string(),
})
}
}
fn is_retryable_status(status: StatusCode) -> bool {
RETRYABLE_STATUSES.contains(&status.as_u16())
}
fn retry_wait(resp: &Response, attempt: u32) -> Duration {
let computed = backoff(attempt);
let wait = match parse_retry_after(resp) {
Some(from_header) => from_header.max(computed),
None => computed,
};
wait.min(MAX_RETRY_WAIT)
}
fn backoff(attempt: u32) -> Duration {
let shift = attempt.min(6);
let base_ms = RETRY_BASE_BACKOFF_MS.saturating_mul(1u64 << shift);
let half = base_ms / 2;
let span = half.saturating_mul(2).saturating_add(1);
let jitter = (clock_jitter_nanos() % span) as i64 - half as i64;
let wait_ms = (base_ms as i64 + jitter).max(0) as u64;
Duration::from_millis(wait_ms).min(MAX_RETRY_WAIT)
}
fn header_string(resp: &Response, name: HeaderName) -> Option<String> {
resp.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
}
fn transport_reason(e: reqwest::Error) -> String {
let e = e.without_url();
let mut reason = e.to_string();
let mut source = StdError::source(&e);
while let Some(cause) = source {
let _ = write!(reason, ": {cause}");
source = cause.source();
}
reason
}
fn find_egress_denied(err: &reqwest::Error) -> Option<EgressDenied> {
let mut source: Option<&(dyn StdError + 'static)> = StdError::source(err);
while let Some(e) = source {
if let Some(denied) = e.downcast_ref::<EgressDenied>() {
return Some(EgressDenied {
host: denied.host.clone(),
ip: denied.ip,
reason: denied.reason.clone(),
});
}
source = e.source();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sources::providers::rss::egress::{EgressPolicy, EgressReason};
use crate::sources::providers::rss::testutil::{MockFeedServer, MockResponse, MockResponseExt};
use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
#[derive(Debug)]
struct DenyList(Vec<IpAddr>);
impl EgressPolicy for DenyList {
fn check_ip(&self, ip: IpAddr) -> Result<(), EgressReason> {
if self.0.contains(&ip) {
Err("test-denied".into())
} else {
Ok(())
}
}
}
fn fetcher_with_policy(policy: Arc<dyn EgressPolicy>) -> FeedFetcher {
FeedFetcher::new(
Some(policy),
Duration::from_secs(2),
1024 * 1024,
"skardi-test".to_string(),
)
.expect("build test fetcher")
}
fn test_fetcher() -> FeedFetcher {
FeedFetcher::new(
None,
Duration::from_secs(2),
1024 * 1024,
"skardi-test".to_string(),
)
.expect("build test fetcher")
}
#[tokio::test]
async fn full_fetch_returns_body_and_validators() {
let server = MockFeedServer::start(|_req| {
MockResponse::xml("<rss/>")
.with_header("etag", "\"v1\"")
.with_header("last-modified", "Mon, 20 Jul 2026 10:00:00 GMT")
})
.await;
let f = test_fetcher();
let out = f
.fetch(&format!("{}/feed.xml", server.url()), None)
.await
.unwrap();
match out {
FetchOutcome::Fetched {
body,
http_status,
etag,
last_modified,
content_type,
..
} => {
assert_eq!(body, b"<rss/>");
assert_eq!(http_status, 200);
assert_eq!(etag.as_deref(), Some("\"v1\""));
assert!(last_modified.is_some());
assert_eq!(content_type.as_deref(), Some("application/xml"));
}
other => panic!("expected Fetched, got {other:?}"),
}
assert_eq!(
server.requests()[0].header("user-agent").as_deref(),
Some("skardi-test")
);
}
#[tokio::test]
async fn conditional_get_sends_validators_and_maps_304() {
let server = MockFeedServer::start(|req| {
if req.header("if-none-match").as_deref() == Some("\"v1\"") {
MockResponse::status(304)
} else {
MockResponse::xml("<rss/>")
}
})
.await;
let f = test_fetcher();
let v = Validators {
etag: Some("\"v1\"".into()),
last_modified: Some("Mon, 20 Jul 2026 10:00:00 GMT".into()),
};
let out = f
.fetch(&format!("{}/f", server.url()), Some(&v))
.await
.unwrap();
assert!(matches!(
out,
FetchOutcome::NotModified { http_status: 304 }
));
let req = &server.requests()[0];
assert_eq!(req.header("if-none-match").as_deref(), Some("\"v1\""));
assert_eq!(
req.header("if-modified-since").as_deref(),
Some("Mon, 20 Jul 2026 10:00:00 GMT")
);
}
#[tokio::test]
async fn unconditional_304_without_validators_is_a_status_error() {
let server = MockFeedServer::start(|_req| MockResponse::status(304)).await;
let f = test_fetcher();
let err = f
.fetch(&format!("{}/f", server.url()), None)
.await
.unwrap_err();
assert!(
matches!(err, FetchError::Status { status: 304 }),
"got {err}"
);
}
#[tokio::test]
async fn empty_validators_answered_with_304_is_a_status_error() {
let server = MockFeedServer::start(|_req| MockResponse::status(304)).await;
let f = test_fetcher();
let v = Validators {
etag: None,
last_modified: None,
};
let err = f
.fetch(&format!("{}/f", server.url()), Some(&v))
.await
.unwrap_err();
assert!(
matches!(err, FetchError::Status { status: 304 }),
"got {err}"
);
let req = &server.requests()[0];
assert!(
req.header("if-none-match").is_none(),
"an empty Validators must attach no If-None-Match"
);
assert!(
req.header("if-modified-since").is_none(),
"an empty Validators must attach no If-Modified-Since"
);
}
#[tokio::test]
async fn validators_are_not_resent_after_redirect() {
let server = MockFeedServer::start(|req| {
if req.path == "/moved" {
assert!(
req.header("if-none-match").is_none(),
"validators must not be resent past the first hop"
);
MockResponse::xml("<rss/>")
} else {
MockResponse::status(302).with_header("location", "/moved")
}
})
.await;
let f = test_fetcher();
let v = Validators {
etag: Some("\"v1\"".into()),
last_modified: None,
};
let out = f
.fetch(&format!("{}/feed.xml", server.url()), Some(&v))
.await
.unwrap();
assert!(matches!(out, FetchOutcome::Fetched { .. }));
let requests = server.requests();
assert_eq!(requests.len(), 2);
assert_eq!(
requests[0].header("if-none-match").as_deref(),
Some("\"v1\""),
"the first hop must still send the validator"
);
assert!(
requests[1].header("if-none-match").is_none(),
"the redirect hop must not resend it"
);
}
#[tokio::test]
async fn unconditional_304_after_redirect_is_a_status_error() {
let server = MockFeedServer::start(|req| {
if req.path == "/moved" {
MockResponse::status(304)
} else {
MockResponse::status(302).with_header("location", "/moved")
}
})
.await;
let f = test_fetcher();
let v = Validators {
etag: Some("\"v1\"".into()),
last_modified: None,
};
let err = f
.fetch(&format!("{}/feed.xml", server.url()), Some(&v))
.await
.unwrap_err();
assert!(
matches!(err, FetchError::Status { status: 304 }),
"got {err}"
);
assert_eq!(server.requests().len(), 2);
}
#[tokio::test]
async fn oversized_body_aborts_with_too_large() {
let big = vec![0u8; 2 * 1024 * 1024];
let server = MockFeedServer::start(move |_req| MockResponse::new(200, big.clone())).await;
let f = test_fetcher();
let err = f
.fetch(&format!("{}/f", server.url()), None)
.await
.unwrap_err();
assert!(
matches!(err, FetchError::TooLarge { limit: 1_048_576 }),
"got {err}"
);
assert_eq!(
server.requests().len(),
1,
"TooLarge is a policy verdict, not a transient fault — a retry \
would only stream the same oversized body again"
);
}
#[tokio::test]
async fn truncated_body_is_retried_and_recovers() {
let calls = Arc::new(AtomicUsize::new(0));
let calls2 = Arc::clone(&calls);
let server = MockFeedServer::start(move |_req| {
if calls2.fetch_add(1, Ordering::SeqCst) == 0 {
MockResponse::xml("<rss>complete</rss>").with_truncated_body(4)
} else {
MockResponse::xml("<rss>complete</rss>")
}
})
.await;
let f = test_fetcher();
let out = f.fetch(&format!("{}/f", server.url()), None).await.unwrap();
match out {
FetchOutcome::Fetched { body, .. } => assert_eq!(
body, b"<rss>complete</rss>",
"the retry's intact body, not the truncated first transfer"
),
other => panic!("expected Fetched, got {other:?}"),
}
assert_eq!(
server.requests().len(),
2,
"a mid-body connection loss must be retried within the hop's \
attempt budget, not treated as terminal"
);
}
#[tokio::test]
async fn a_transport_error_names_the_cause_but_never_the_url() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let f = test_fetcher();
let err = f
.fetch(
&format!("http://127.0.0.1:{port}/feed.xml?token=secret"),
None,
)
.await
.unwrap_err();
assert!(matches!(err, FetchError::Transport { .. }), "{err}");
let msg = err.to_string();
assert!(
!msg.contains("token=secret") && !msg.contains("for url"),
"reqwest's URL clause must be stripped: {msg}"
);
assert!(
msg.contains("refused") || msg.contains("connect"),
"the folded source chain carries the actual cause: {msg}"
);
}
#[tokio::test]
async fn an_unresolvable_location_redacts_the_current_url() {
let server = MockFeedServer::start(|_req| {
MockResponse::status(302).with_header("location", "http://")
})
.await;
let f = test_fetcher();
let url = format!("{}/feed.xml?token=secret", server.url());
let err = f.fetch(&url, None).await.unwrap_err();
assert!(matches!(err, FetchError::InvalidUrl { .. }), "{err}");
let msg = err.to_string();
assert!(!msg.contains("token=secret"), "{msg}");
assert!(
msg.contains("redirect location 'http://'"),
"the offending location itself is the diagnostic: {msg}"
);
assert!(
msg.contains("/feed.xml'"),
"the redacted current URL still locates the hop: {msg}"
);
}
#[tokio::test]
async fn an_unparseable_url_is_not_echoed_into_the_error() {
let f = test_fetcher();
let err = f.fetch("not a url?token=secret", None).await.unwrap_err();
assert!(matches!(err, FetchError::InvalidUrl { .. }), "{err}");
let msg = err.to_string();
assert!(!msg.contains("token=secret"), "{msg}");
}
#[tokio::test]
async fn redirect_is_followed_and_validated() {
let server = MockFeedServer::start(|req| {
if req.path == "/moved" {
MockResponse::xml("<rss/>")
} else {
MockResponse::status(302).with_header("location", "/moved")
}
})
.await;
let f = test_fetcher();
let out = f
.fetch(&format!("{}/feed.xml", server.url()), None)
.await
.unwrap();
assert!(matches!(out, FetchOutcome::Fetched { .. }));
let requests = server.requests();
assert_eq!(requests.len(), 2);
assert_eq!(requests[1].path, "/moved");
}
#[tokio::test]
async fn a_redirected_fetch_reports_its_landing_url() {
let server = MockFeedServer::start(|req| {
if req.path == "/moved" {
MockResponse::xml("<rss/>").with_header("etag", "\"landing\"")
} else {
MockResponse::status(301).with_header("location", "/moved")
}
})
.await;
let f = test_fetcher();
let requested = format!("{}/feed.xml", server.url());
let out = f.fetch(&requested, None).await.unwrap();
match out {
FetchOutcome::Fetched {
final_url, etag, ..
} => {
assert_eq!(final_url, format!("{}/moved", server.url()));
assert_ne!(final_url, requested, "the landing URL, not the request");
assert_eq!(
etag.as_deref(),
Some("\"landing\""),
"and the validators beside it are the landing hop's"
);
}
other => panic!("expected Fetched, got {other:?}"),
}
}
#[tokio::test]
async fn an_undirected_fetch_reports_the_requested_url() {
let server = MockFeedServer::start(|_req| MockResponse::xml("<rss/>")).await;
let f = test_fetcher();
let requested = format!("{}/feed.xml", server.url());
match f.fetch(&requested, None).await.unwrap() {
FetchOutcome::Fetched { final_url, .. } => assert_eq!(final_url, requested),
other => panic!("expected Fetched, got {other:?}"),
}
}
#[tokio::test]
async fn redirect_location_with_raw_utf8_is_percent_encoded_and_followed() {
let server = MockFeedServer::start(|req| {
if req.path == "/art%C3%ADculo" {
MockResponse::xml("<rss/>")
} else {
MockResponse::status(302).with_header("location", "/artículo")
}
})
.await;
let f = test_fetcher();
let out = f
.fetch(&format!("{}/feed.xml", server.url()), None)
.await
.unwrap();
assert!(matches!(out, FetchOutcome::Fetched { .. }));
let requests = server.requests();
assert_eq!(requests.len(), 2);
assert_eq!(
requests[1].path, "/art%C3%ADculo",
"raw-UTF-8 Location octets must be percent-encoded before the hop"
);
}
#[tokio::test]
async fn too_many_redirects_errors() {
let server = MockFeedServer::start(|_req| {
MockResponse::status(302).with_header("location", "/next")
})
.await;
let f = test_fetcher();
let err = f
.fetch(&format!("{}/start", server.url()), None)
.await
.unwrap_err();
assert!(
matches!(err, FetchError::TooManyRedirects { hops: 5 }),
"got {err}"
);
assert_eq!(server.requests().len() as u32, MAX_REDIRECT_HOPS + 1);
}
#[tokio::test]
async fn retryable_statuses_retry_with_retry_after() {
let calls = Arc::new(AtomicUsize::new(0));
let calls2 = Arc::clone(&calls);
let server = MockFeedServer::start(move |_req| {
if calls2.fetch_add(1, Ordering::SeqCst) == 0 {
MockResponse::status(429).with_header("retry-after", "1")
} else {
MockResponse::xml("<rss/>")
}
})
.await;
let f = test_fetcher();
let start = Instant::now();
let out = f.fetch(&format!("{}/f", server.url()), None).await.unwrap();
assert!(matches!(out, FetchOutcome::Fetched { .. }));
assert_eq!(server.requests().len(), 2);
assert!(
start.elapsed() >= Duration::from_secs(1),
"elapsed {:?}, expected the 1s retry-after to be honored",
start.elapsed()
);
}
#[tokio::test]
async fn retryable_statuses_retry_with_http_date_retry_after() {
let calls = Arc::new(AtomicUsize::new(0));
let calls2 = Arc::clone(&calls);
let server = MockFeedServer::start(move |_req| {
if calls2.fetch_add(1, Ordering::SeqCst) == 0 {
let date = httpdate::fmt_http_date(SystemTime::now() + Duration::from_secs(2));
MockResponse::status(429).with_header("retry-after", &date)
} else {
MockResponse::xml("<rss/>")
}
})
.await;
let f = test_fetcher();
let start = Instant::now();
let out = f.fetch(&format!("{}/f", server.url()), None).await.unwrap();
assert!(matches!(out, FetchOutcome::Fetched { .. }));
assert_eq!(server.requests().len(), 2);
assert!(
start.elapsed() >= Duration::from_secs(1),
"elapsed {:?}, expected the http-date retry-after to be honored",
start.elapsed()
);
}
#[tokio::test]
async fn retry_after_is_capped_at_max_retry_wait() {
let calls = Arc::new(AtomicUsize::new(0));
let calls2 = Arc::clone(&calls);
let server = MockFeedServer::start(move |_req| {
if calls2.fetch_add(1, Ordering::SeqCst) == 0 {
MockResponse::status(429).with_header("retry-after", "999999")
} else {
MockResponse::xml("<rss/>")
}
})
.await;
let f = test_fetcher();
let start = Instant::now();
let out = tokio::time::timeout(
Duration::from_secs(20),
f.fetch(&format!("{}/f", server.url()), None),
)
.await
.expect(
"fetch did not complete within 20s — the Retry-After clamp appears to have \
regressed (an uncapped 999999s wait would hang far longer than this)",
)
.unwrap();
assert!(matches!(out, FetchOutcome::Fetched { .. }));
assert_eq!(server.requests().len(), 2);
let elapsed = start.elapsed();
assert!(
elapsed >= Duration::from_secs(9) && elapsed <= Duration::from_secs(12),
"elapsed {elapsed:?}, expected close to MAX_RETRY_WAIT (10s) — \
a 999999s retry-after honored literally would fail the 20s timeout above instead, \
but this window is what actually pins the clamp to ~10s rather than merely \"fast\"",
);
}
#[tokio::test]
async fn retries_exhaust_to_status_error() {
for status in [429, 500, 502, 503, 504] {
let server = MockFeedServer::start(move |_req| MockResponse::status(status)).await;
let f = test_fetcher();
let err = f
.fetch(&format!("{}/f", server.url()), None)
.await
.unwrap_err();
match err {
FetchError::Status { status: got } => assert_eq!(
got, status,
"the terminal error carries the status that kept failing"
),
other => panic!("expected a Status error for {status}, got {other:?}"),
}
assert_eq!(
server.requests().len() as u32,
MAX_ATTEMPTS,
"{status} must be retried to the attempt budget, not treated as terminal"
);
}
}
#[tokio::test]
async fn non_retryable_status_fails_immediately() {
let server = MockFeedServer::start(|_req| MockResponse::status(404)).await;
let f = test_fetcher();
let err = f
.fetch(&format!("{}/f", server.url()), None)
.await
.unwrap_err();
assert!(
matches!(err, FetchError::Status { status: 404 }),
"got {err}"
);
assert_eq!(server.requests().len(), 1);
}
#[tokio::test]
async fn spurious_206_is_a_status_error_not_a_cached_window() {
let server = MockFeedServer::start(|_req| {
MockResponse::new(206, "<rss/>".as_bytes().to_vec())
.with_header("content-type", "application/xml")
.with_header("etag", "\"v1\"")
})
.await;
let f = test_fetcher();
let err = f
.fetch(&format!("{}/f", server.url()), None)
.await
.unwrap_err();
assert!(
matches!(err, FetchError::Status { status: 206 }),
"got {err}"
);
assert_eq!(server.requests().len(), 1, "a 2xx mismatch is not retried");
}
#[tokio::test]
async fn spurious_204_is_a_status_error_not_an_empty_success() {
let server = MockFeedServer::start(|_req| MockResponse::status(204)).await;
let f = test_fetcher();
let err = f
.fetch(&format!("{}/f", server.url()), None)
.await
.unwrap_err();
assert!(
matches!(err, FetchError::Status { status: 204 }),
"got {err}"
);
assert_eq!(server.requests().len(), 1);
}
#[tokio::test]
async fn request_timeout_maps_to_timeout_error() {
let server = MockFeedServer::start(|_req| {
MockResponse::xml("<rss/>").with_delay(Duration::from_secs(3))
})
.await;
let f = FeedFetcher::new(
None,
Duration::from_secs(1),
1024 * 1024,
"skardi-test".to_string(),
)
.expect("build fetcher");
let err = f
.fetch(&format!("{}/f", server.url()), None)
.await
.unwrap_err();
assert!(
matches!(err, FetchError::Timeout { seconds: 1 }),
"got {err}"
);
}
#[tokio::test]
async fn injected_policy_refuses_ip_literal_on_initial_url() {
let policy = Arc::new(DenyList(vec!["192.168.0.1".parse().unwrap()]));
let f = fetcher_with_policy(policy);
let err = f.fetch("http://192.168.0.1:9/f", None).await.unwrap_err();
match err {
FetchError::Egress(e) => assert_eq!(e.reason, "test-denied", "got {e}"),
other => panic!("expected Egress, got {other:?}"),
}
}
#[tokio::test]
async fn injected_policy_refuses_mapped_ipv6_literal_on_initial_url() {
let policy = Arc::new(DenyList(vec!["10.0.0.1".parse().unwrap()]));
let f = fetcher_with_policy(policy);
let url = "http://[::ffff:10.0.0.1]:9/f";
let err = f.fetch(url, None).await.unwrap_err();
match err {
FetchError::Egress(e) => {
assert_eq!(e.reason, "test-denied", "got {e}");
assert_eq!(e.ip, "10.0.0.1".parse::<IpAddr>().unwrap());
let written_host = Url::parse(url).unwrap().host_str().unwrap().to_string();
assert_eq!(e.host, written_host);
assert_ne!(
e.host,
e.ip.to_string(),
"host must not collapse to the canonical ip"
);
}
other => panic!("expected Egress, got {other:?}"),
}
}
#[tokio::test]
async fn injected_policy_refuses_redirect_target() {
let server = MockFeedServer::start(|_req| {
MockResponse::status(302).with_header("location", "http://10.255.255.1/f")
})
.await;
let policy = Arc::new(DenyList(vec!["10.255.255.1".parse().unwrap()]));
let f = fetcher_with_policy(policy);
let err = f
.fetch(&format!("{}/start", server.url()), None)
.await
.unwrap_err();
match err {
FetchError::Egress(e) => assert_eq!(e.reason, "test-denied", "got {e}"),
other => panic!("expected Egress, got {other:?}"),
}
assert_eq!(
server.requests().len(),
1,
"the denied redirect target must never be connected to"
);
}
#[tokio::test]
async fn injected_policy_refuses_mapped_ipv6_redirect_target() {
let server = MockFeedServer::start(|_req| {
MockResponse::status(302).with_header("location", "http://[::ffff:10.0.0.1]/f")
})
.await;
let policy = Arc::new(DenyList(vec!["10.0.0.1".parse().unwrap()]));
let f = fetcher_with_policy(policy);
let err = f
.fetch(&format!("{}/start", server.url()), None)
.await
.unwrap_err();
match err {
FetchError::Egress(e) => assert_eq!(e.reason, "test-denied", "got {e}"),
other => panic!("expected Egress, got {other:?}"),
}
assert_eq!(
server.requests().len(),
1,
"the denied redirect target must never be connected to"
);
}
#[tokio::test]
async fn injected_policy_refuses_hostname_via_resolver() {
let server = MockFeedServer::start(|_req| MockResponse::xml("<rss/>")).await;
let localhost_url = server.url().replace("127.0.0.1", "localhost");
let policy = Arc::new(DenyList(vec![
"127.0.0.1".parse().unwrap(),
"::1".parse().unwrap(),
]));
let f = fetcher_with_policy(policy);
let err = f
.fetch(&format!("{localhost_url}/f"), None)
.await
.unwrap_err();
assert!(matches!(err, FetchError::Egress(_)), "got {err}");
}
#[tokio::test]
async fn injected_policy_refuses_hostname_redirect_target_via_resolver() {
let localhost_resolves_v6 = tokio::net::lookup_host("localhost:0")
.await
.map(|addrs| {
addrs
.map(|a| a.ip())
.any(|ip| ip.is_loopback() && ip.is_ipv6())
})
.unwrap_or(false);
if !localhost_resolves_v6 {
eprintln!(
"skipping: `localhost` does not resolve to ::1 on this host \
(IPv6 disabled or no `::1 localhost` entry); the IPv4-allow/\
IPv6-deny asymmetry this test turns on needs a dual-stack \
localhost"
);
return;
}
let server = MockFeedServer::start(|req| {
let host = req.header("host").expect("reqwest sends a host header");
let redirect_to = format!("http://{}/denied", host.replace("127.0.0.1", "localhost"));
MockResponse::status(302).with_header("location", &redirect_to)
})
.await;
let policy = Arc::new(DenyList(vec!["::1".parse().unwrap()]));
let f = fetcher_with_policy(policy);
let err = f
.fetch(&format!("{}/start", server.url()), None)
.await
.unwrap_err();
match err {
FetchError::Egress(e) => assert_eq!(e.reason, "test-denied", "got {e}"),
other => panic!("expected Egress, got {other:?}"),
}
assert_eq!(
server.requests().len(),
1,
"only /start was ever connected; the denied hostname target was \
refused before any connection"
);
}
#[tokio::test]
#[ignore = "subprocess half of proxy_env_vars_do_not_bypass_the_egress_policy"]
async fn proxy_env_check_in_child_process() {
if std::env::var("HTTP_PROXY").is_err() {
eprintln!(
"skipping: no proxy variables in the environment — run via \
proxy_env_vars_do_not_bypass_the_egress_policy"
);
return;
}
let server = MockFeedServer::start(|_req| MockResponse::xml("<rss/>")).await;
let localhost_url = server.url().replace("127.0.0.1", "localhost");
let policy = Arc::new(DenyList(vec![
"127.0.0.1".parse().unwrap(),
"::1".parse().unwrap(),
]));
let f = fetcher_with_policy(policy);
let err = f
.fetch(&format!("{localhost_url}/f"), None)
.await
.unwrap_err();
assert!(matches!(err, FetchError::Egress(_)), "got {err}");
assert_eq!(
server.requests().len(),
0,
"the denied hostname must never be connected to, proxied or not"
);
}
#[test]
fn proxy_env_vars_do_not_bypass_the_egress_policy() {
let exe = std::env::current_exe().expect("locate the running test binary");
let output = Command::new(exe)
.args([
"--exact",
"sources::providers::rss::fetch::tests::proxy_env_check_in_child_process",
"--ignored",
"--nocapture",
])
.env("HTTP_PROXY", "http://127.0.0.1:1")
.env("http_proxy", "http://127.0.0.1:1")
.output()
.expect("spawn the child test process");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"child process failed — the egress policy did not hold under \
proxy variables\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
stdout.contains("1 passed"),
"the child ran zero tests — filter out of date?\nstdout:\n{stdout}"
);
}
#[tokio::test]
#[ignore = "subprocess half of no_policy_fetcher_honors_proxy_env_vars"]
async fn no_policy_proxy_check_in_child_process() {
if std::env::var("HTTP_PROXY").is_err() {
eprintln!(
"skipping: no proxy variables in the environment — run via \
no_policy_fetcher_honors_proxy_env_vars"
);
return;
}
let server = MockFeedServer::start(|_req| MockResponse::xml("<rss/>")).await;
let f = test_fetcher();
let err = f
.fetch(&format!("{}/f", server.url()), None)
.await
.unwrap_err();
assert!(
matches!(
err,
FetchError::Transport { .. } | FetchError::Timeout { .. }
),
"expected the fetch to fail against the unreachable proxy, got {err}"
);
assert_eq!(
server.requests().len(),
0,
"with no policy injected the request must go to the proxy, \
never directly to the target"
);
}
#[test]
fn no_policy_fetcher_honors_proxy_env_vars() {
let exe = std::env::current_exe().expect("locate the running test binary");
let output = Command::new(exe)
.args([
"--exact",
"sources::providers::rss::fetch::tests::no_policy_proxy_check_in_child_process",
"--ignored",
"--nocapture",
])
.env("HTTP_PROXY", "http://127.0.0.1:1")
.env("http_proxy", "http://127.0.0.1:1")
.output()
.expect("spawn the child test process");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"child process failed — the no-policy fetcher did not honor \
proxy variables\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
stdout.contains("1 passed"),
"the child ran zero tests — filter out of date?\nstdout:\n{stdout}"
);
}
#[tokio::test]
async fn https_and_http_only() {
let f = test_fetcher();
let err = f
.fetch("ftp://example.com/feed.xml", None)
.await
.unwrap_err();
assert!(matches!(err, FetchError::InvalidUrl { .. }), "got {err}");
}
#[tokio::test]
async fn invalid_user_agent_fails_client_construction() {
let err = FeedFetcher::new(
None,
Duration::from_secs(2),
1024 * 1024,
"bad\nua".to_string(),
)
.unwrap_err();
assert!(matches!(err, RssError::HttpClientBuild { .. }), "got {err}");
}
}