use std::time::Duration;
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, RETRY_AFTER};
use reqwest::StatusCode;
use serde::Deserialize;
use crate::atproto::urlencode;
pub const LIST_REPOS_BY_COLLECTION: &str = "com.atproto.sync.listReposByCollection";
pub const DEFAULT_RELAY_HOSTS: [&str; 2] =
["relay1.us-west.bsky.network", "relay1.us-east.bsky.network"];
pub const DEFAULT_PAGE_LIMIT: u32 = 500;
pub const MAX_PAGES: usize = 50;
const MIN_PAGE_LIMIT: u32 = 1;
const MAX_PAGE_LIMIT: u32 = 1000;
const DEFAULT_PAGE_DELAY: Duration = Duration::from_secs(1);
const DEFAULT_HOST_BUDGET: Duration = Duration::from_secs(120);
const HARD_DEADLINE_SLACK: Duration = Duration::from_secs(10);
const ERROR_SNIPPET_CHARS: usize = 200;
#[derive(Debug, Clone, Deserialize)]
struct ListReposByCollectionOut {
#[serde(default)]
repos: Vec<RepoRef>,
#[serde(default)]
cursor: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct RepoRef {
#[allow(dead_code)]
#[serde(default)]
did: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdoptionObservation {
pub source: String,
pub collection: String,
pub repos: u64,
pub truncated: bool,
pub observed_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelayFailure {
pub host: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdoptionReport {
pub collection: String,
pub observations: Vec<AdoptionObservation>,
pub failures: Vec<RelayFailure>,
}
impl AdoptionReport {
pub fn succeeded(&self) -> bool {
!self.observations.is_empty()
}
pub fn best(&self) -> Option<&AdoptionObservation> {
self.observations.iter().fold(None, |best, obs| match best {
Some(b) if b.repos >= obs.repos => Some(b),
_ => Some(obs),
})
}
pub fn disagrees(&self) -> bool {
let mut counts = self.observations.iter().map(|o| o.repos);
match counts.next() {
Some(first) => counts.any(|n| n != first),
None => false,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum RelayError {
#[error("relay host {host:?} is not a usable http(s) target: {reason}")]
BadHost {
host: String,
reason: String,
},
#[error("relay request to {host:?} failed: {reason}")]
Transport {
host: String,
reason: String,
},
#[error("relay {host:?} returned HTTP {status}: {error}")]
Http {
host: String,
status: StatusCode,
error: String,
},
#[error("relay {host:?} rate-limited the probe (429){}",
match retry_after {
Some(secs) => format!(", Retry-After: {secs}s"),
None => String::new(),
})]
RateLimited {
host: String,
retry_after: Option<u64>,
},
#[error("relay {host:?} returned an unparseable body: {reason}")]
Malformed {
host: String,
reason: String,
},
#[error("relay {host:?} did not answer within {after:?}")]
TimedOut {
host: String,
after: Duration,
},
}
pub fn normalize_relay_host(raw: &str) -> Result<String, RelayError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(RelayError::BadHost {
host: raw.to_string(),
reason: "empty".to_string(),
});
}
let candidate = if trimmed.contains("://") {
trimmed.to_string()
} else {
format!("https://{trimmed}")
};
let url = url::Url::parse(&candidate).map_err(|err| RelayError::BadHost {
host: raw.to_string(),
reason: err.to_string(),
})?;
if !matches!(url.scheme(), "http" | "https") {
return Err(RelayError::BadHost {
host: raw.to_string(),
reason: format!("scheme {:?} is not http(s)", url.scheme()),
});
}
let host = url.host_str().ok_or_else(|| RelayError::BadHost {
host: raw.to_string(),
reason: "no host component".to_string(),
})?;
let mut origin = format!("{}://{}", url.scheme(), host);
if let Some(port) = url.port() {
origin.push_str(&format!(":{port}"));
}
Ok(origin)
}
#[derive(Debug, Clone)]
pub struct RelayClient {
http: reqwest::Client,
hosts: Vec<String>,
page_limit: u32,
max_pages: usize,
page_delay: Duration,
host_budget: Duration,
}
impl RelayClient {
pub fn new(http: reqwest::Client, hosts: &[String]) -> Result<Self, RelayError> {
let mut normalized: Vec<String> = Vec::with_capacity(hosts.len());
for raw in hosts {
let host = normalize_relay_host(raw)?;
if !normalized.contains(&host) {
normalized.push(host);
}
}
Ok(Self {
http,
hosts: normalized,
page_limit: DEFAULT_PAGE_LIMIT,
max_pages: MAX_PAGES,
page_delay: DEFAULT_PAGE_DELAY,
host_budget: DEFAULT_HOST_BUDGET,
})
}
pub fn with_page_limit(mut self, limit: u32) -> Self {
self.page_limit = limit.clamp(MIN_PAGE_LIMIT, MAX_PAGE_LIMIT);
self
}
fn hard_deadline(&self) -> Duration {
self.host_budget + crate::net::WORST_CASE_REQUEST + HARD_DEADLINE_SLACK
}
pub fn hosts(&self) -> &[String] {
&self.hosts
}
pub fn is_enabled(&self) -> bool {
!self.hosts.is_empty()
}
pub async fn count_repos_with_collection(&self, collection: &str) -> AdoptionReport {
let mut observations = Vec::new();
let mut failures = Vec::new();
for host in &self.hosts {
match tokio::time::timeout(self.hard_deadline(), self.count_on_host(host, collection))
.await
{
Ok(Ok(obs)) => observations.push(obs),
Ok(Err(err)) => {
let rate_limited = matches!(err, RelayError::RateLimited { .. });
failures.push(RelayFailure {
host: host.clone(),
reason: err.to_string(),
});
if rate_limited {
break;
}
}
Err(_) => failures.push(RelayFailure {
host: host.clone(),
reason: RelayError::TimedOut {
host: host.clone(),
after: self.hard_deadline(),
}
.to_string(),
}),
}
}
AdoptionReport {
collection: collection.to_string(),
observations,
failures,
}
}
async fn count_on_host(
&self,
host: &str,
collection: &str,
) -> Result<AdoptionObservation, RelayError> {
let (repos, truncated) = self
.walk_pages(host, |cursor| self.fetch_page(host, collection, cursor))
.await?;
Ok(self.observe(host, collection, repos, truncated))
}
async fn walk_pages<F, Fut>(&self, host: &str, mut fetch: F) -> Result<(u64, bool), RelayError>
where
F: FnMut(Option<String>) -> Fut,
Fut: std::future::Future<Output = Result<ListReposByCollectionOut, RelayError>>,
{
let started = tokio::time::Instant::now();
let mut repos: u64 = 0;
let mut cursor: Option<String> = None;
for page_no in 0..self.max_pages {
let remaining = self.host_budget.saturating_sub(started.elapsed());
if page_no > 0 && remaining.is_zero() {
return Ok((repos, true));
}
if page_no > 0 && !self.page_delay.is_zero() {
tokio::time::sleep(self.page_delay).await;
}
let sent = cursor.take();
let page = match tokio::time::timeout(remaining, fetch(sent.clone())).await {
Ok(res) => res?,
Err(_) => return Ok((repos, true)),
};
repos = repos.saturating_add(page.repos.len() as u64);
match advance(&page) {
PageStep::Continue(next) if Some(&next) == sent.as_ref() => {
return Err(RelayError::Malformed {
host: host.to_string(),
reason: format!(
"repeated cursor {next:?} at page {page_no} instead of advancing"
),
})
}
PageStep::Continue(next) => cursor = Some(next),
PageStep::Done => return Ok((repos, false)),
}
}
Ok((repos, true))
}
async fn fetch_page(
&self,
host: &str,
collection: &str,
cursor: Option<String>,
) -> Result<ListReposByCollectionOut, RelayError> {
let url = self.page_url(host, collection, cursor.as_deref());
let resp = crate::net::guarded_get_no_privacy(
&self.http,
&url,
&[(ACCEPT, HeaderValue::from_static("application/json"))],
)
.await
.map_err(|err| RelayError::Transport {
host: host.to_string(),
reason: format!("{err:#}"),
})?;
if resp.status() == StatusCode::TOO_MANY_REQUESTS {
return Err(RelayError::RateLimited {
host: host.to_string(),
retry_after: retry_after_secs(resp.headers()),
});
}
if !resp.status().is_success() {
let status = resp.status();
let snippet = crate::net::read_capped(resp)
.await
.ok()
.map(|body| {
String::from_utf8_lossy(&body)
.chars()
.take(ERROR_SNIPPET_CHARS)
.collect::<String>()
})
.unwrap_or_default();
return Err(RelayError::Http {
host: host.to_string(),
status,
error: snippet,
});
}
let body = crate::net::read_capped(resp)
.await
.map_err(|err| RelayError::Transport {
host: host.to_string(),
reason: format!("{err:#}"),
})?;
serde_json::from_slice(&body).map_err(|err| RelayError::Malformed {
host: host.to_string(),
reason: err.to_string(),
})
}
fn page_url(&self, host: &str, collection: &str, cursor: Option<&str>) -> String {
let mut url = format!(
"{}/xrpc/{}?collection={}&limit={}",
host.trim_end_matches('/'),
LIST_REPOS_BY_COLLECTION,
urlencode(collection),
self.page_limit,
);
if let Some(cursor) = cursor {
url.push_str(&format!("&cursor={}", urlencode(cursor)));
}
url
}
fn observe(
&self,
host: &str,
collection: &str,
repos: u64,
truncated: bool,
) -> AdoptionObservation {
AdoptionObservation {
source: host.to_string(),
collection: collection.to_string(),
repos,
truncated,
observed_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum PageStep {
Continue(String),
Done,
}
fn advance(page: &ListReposByCollectionOut) -> PageStep {
match &page.cursor {
Some(next) if !page.repos.is_empty() => PageStep::Continue(next.clone()),
_ => PageStep::Done,
}
}
fn retry_after_secs(headers: &HeaderMap) -> Option<u64> {
headers
.get(RETRY_AFTER)?
.to_str()
.ok()?
.trim()
.parse::<u64>()
.ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn test_client() -> RelayClient {
RelayClient::new(
reqwest::Client::builder().build().unwrap(),
&[DEFAULT_RELAY_HOSTS[0].to_string()],
)
.unwrap()
}
fn instant_client(max_pages: usize) -> RelayClient {
let mut c = test_client();
c.max_pages = max_pages;
c.page_delay = Duration::ZERO;
c
}
fn parse(body: &str) -> ListReposByCollectionOut {
serde_json::from_str(body).expect("relay body")
}
fn obs(source: &str, repos: u64) -> AdoptionObservation {
AdoptionObservation {
source: source.to_string(),
collection: crate::lexicon::nsid::SUBSCRIPTION.to_string(),
repos,
truncated: false,
observed_at: "2026-08-13T00:00:00Z".to_string(),
}
}
#[test]
fn normalize_defaults_bare_host_to_https() {
assert_eq!(
normalize_relay_host("relay1.us-west.bsky.network").unwrap(),
"https://relay1.us-west.bsky.network"
);
assert_eq!(
normalize_relay_host(" relay1.us-east.bsky.network ").unwrap(),
"https://relay1.us-east.bsky.network"
);
}
#[test]
fn normalize_strips_trailing_slash_and_path() {
assert_eq!(
normalize_relay_host("https://relay.example/").unwrap(),
"https://relay.example"
);
assert_eq!(
normalize_relay_host("https://relay.example/xrpc/whatever?x=1#f").unwrap(),
"https://relay.example"
);
assert_eq!(
normalize_relay_host("http://relay.example:8080/").unwrap(),
"http://relay.example:8080"
);
}
#[test]
fn normalize_rejects_bad_schemes_and_empties() {
for bad in ["wss://relay.example", "file:///etc/passwd", "", " "] {
assert!(
normalize_relay_host(bad).is_err(),
"{bad:?} should be rejected"
);
}
}
#[test]
fn page_url_without_cursor_is_exact() {
let c = test_client();
assert_eq!(
c.page_url(
"https://relay1.us-west.bsky.network",
crate::lexicon::nsid::SUBSCRIPTION,
None
),
"https://relay1.us-west.bsky.network/xrpc/com.atproto.sync.listReposByCollection\
?collection=community.lexicon.rss.subscription&limit=500"
);
}
#[test]
fn page_url_percent_encodes_the_cursor() {
let c = test_client();
let url = c.page_url("https://relay.example", "a.b.c", Some("aa+bb/cc=="));
assert!(
url.ends_with("&cursor=aa%2Bbb%2Fcc%3D%3D"),
"cursor must be percent-encoded, got: {url}"
);
assert!(url.contains("?collection=a.b.c&limit=500"), "{url}");
}
#[test]
fn page_limit_is_clamped() {
assert_eq!(test_client().with_page_limit(0).page_limit, MIN_PAGE_LIMIT);
assert_eq!(
test_client().with_page_limit(99_999).page_limit,
MAX_PAGE_LIMIT
);
assert_eq!(test_client().with_page_limit(200).page_limit, 200);
}
#[test]
fn parses_the_live_single_repo_terminal_page() {
let page = parse(r#"{"repos":[{"did":"did:plc:ohutz6x5acjmpuulp3x7wxxc"}]}"#);
assert_eq!(page.repos.len(), 1);
assert_eq!(page.cursor, None);
}
#[test]
fn parses_a_cursor_first_paged_body() {
let page = parse(
r#"{"cursor":"QQAAAGsAAAGVmhd3TGRpZDpwbGM6dGxkYW91amwzNzZ6dTV3ZXphem54ZmV2AA",
"repos":[{"did":"did:plc:qw4uaobncdi5ijsj4mthdboq"},
{"did":"did:plc:tldaoujl376zu5wezaznxfev"}]}"#,
);
assert_eq!(page.repos.len(), 2);
assert!(page.cursor.is_some());
}
#[test]
fn parses_empty_absent_and_unknown_field_bodies() {
let empty = parse(r#"{"repos":[]}"#);
assert_eq!(empty.repos.len(), 0);
assert_eq!(empty.cursor, None);
assert_eq!(parse("{}").repos.len(), 0);
let future = parse(r#"{"repos":[{"did":"did:web:lexicon.store","note":1}],"total":7}"#);
assert_eq!(future.repos.len(), 1);
assert_eq!(future.repos[0].did, "did:web:lexicon.store");
}
#[test]
fn advance_stops_without_a_cursor() {
assert_eq!(
advance(&parse(r#"{"repos":[{"did":"a"}]}"#)),
PageStep::Done
);
}
#[test]
fn advance_continues_on_a_cursor_with_rows() {
assert_eq!(
advance(&parse(r#"{"repos":[{"did":"a"}],"cursor":"c1"}"#)),
PageStep::Continue("c1".to_string())
);
}
#[test]
fn advance_stops_on_an_empty_page_even_with_a_cursor() {
assert_eq!(
advance(&parse(r#"{"repos":[],"cursor":"c1"}"#)),
PageStep::Done
);
}
#[tokio::test]
async fn walk_pages_follows_the_cursor_then_stops() {
let c = instant_client(MAX_PAGES);
let pages = [
r#"{"repos":[{"did":"a"},{"did":"b"}],"cursor":"c1"}"#,
r#"{"repos":[{"did":"c"}]}"#,
];
let mut seen_cursors: Vec<Option<String>> = Vec::new();
let mut n = 0usize;
let (repos, truncated) = c
.walk_pages("https://relay.example", |cursor| {
seen_cursors.push(cursor);
let body = pages[n];
n += 1;
async move { Ok(parse(body)) }
})
.await
.unwrap();
assert_eq!(repos, 3);
assert!(!truncated);
assert_eq!(seen_cursors, vec![None, Some("c1".to_string())]);
}
#[tokio::test]
async fn walk_pages_stops_at_the_page_cap_and_marks_truncated() {
let c = instant_client(3);
let mut n = 0usize;
let (repos, truncated) = c
.walk_pages("https://relay.example", |_| {
n += 1;
let body = format!(r#"{{"repos":[{{"did":"a"}},{{"did":"b"}}],"cursor":"c{n}"}}"#);
async move { Ok(parse(&body)) }
})
.await
.unwrap();
assert_eq!(repos, 6, "3 pages × 2 repos");
assert!(truncated, "hitting the cap makes the count a floor");
}
#[tokio::test(start_paused = true)]
async fn walk_pages_keeps_a_partial_count_when_the_budget_runs_out() {
let mut c = instant_client(50);
c.host_budget = Duration::from_secs(10);
let mut n = 0usize;
let (repos, truncated) = c
.walk_pages("https://relay.example", |_| {
n += 1;
let body = format!(r#"{{"repos":[{{"did":"a"}},{{"did":"b"}}],"cursor":"c{n}"}}"#);
async move {
tokio::time::sleep(Duration::from_secs(6)).await;
Ok(parse(&body))
}
})
.await
.unwrap();
assert_eq!(repos, 2, "page 1's count survives; page 2 never completed");
assert!(
truncated,
"a budget stop makes the count a floor, not a loss"
);
assert_eq!(n, 2, "page 2 was attempted, then cut off at the budget");
}
#[test]
fn the_hard_deadline_clears_one_worst_case_request() {
let c = test_client();
let worst = crate::net::FETCH_TIMEOUT * (crate::net::MAX_REDIRECTS as u32 + 1);
assert_eq!(
crate::net::WORST_CASE_REQUEST,
worst,
"worst-case arithmetic"
);
assert!(
c.hard_deadline() >= c.host_budget + worst,
"hard {:?} must clear soft {:?} + one worst-case request {:?}",
c.hard_deadline(),
c.host_budget,
worst
);
}
#[tokio::test(start_paused = true)]
async fn a_page_that_outruns_the_budget_still_yields_what_was_counted() {
let mut c = instant_client(50);
c.host_budget = Duration::from_secs(10);
let mut n = 0usize;
let (repos, truncated) = c
.walk_pages("https://relay.example", |_| {
n += 1;
let first = n == 1;
async move {
if first {
return Ok(parse(r#"{"repos":[{"did":"a"}],"cursor":"c1"}"#));
}
tokio::time::sleep(Duration::from_secs(600)).await;
Ok(parse(r#"{"repos":[{"did":"b"}],"cursor":"c2"}"#))
}
})
.await
.unwrap();
assert_eq!(repos, 1, "page 1's count survives the hung page 2");
assert!(truncated, "and is reported as a floor");
}
#[test]
fn rate_limited_display_carries_retry_after() {
let with = RelayError::RateLimited {
host: "https://relay.example".to_string(),
retry_after: Some(120),
}
.to_string();
assert!(with.contains("Retry-After: 120s"), "{with}");
let without = RelayError::RateLimited {
host: "https://relay.example".to_string(),
retry_after: None,
}
.to_string();
assert!(!without.contains("Retry-After"), "{without}");
assert!(without.contains("rate-limited"), "{without}");
}
#[tokio::test]
async fn walk_pages_refuses_a_relay_that_repeats_its_cursor() {
let c = instant_client(50);
let err = c
.walk_pages("https://relay.example", |_| async {
Ok(parse(
r#"{"repos":[{"did":"a"},{"did":"b"}],"cursor":"stuck"}"#,
))
})
.await
.unwrap_err();
match err {
RelayError::Malformed { host, reason } => {
assert_eq!(host, "https://relay.example");
assert!(reason.contains("repeated cursor"), "reason was {reason:?}");
}
other => panic!("expected Malformed, got {other:?}"),
}
}
#[tokio::test]
async fn walk_pages_propagates_a_page_error() {
let c = instant_client(MAX_PAGES);
let err = c
.walk_pages("https://relay.example", |_| async {
Err(RelayError::Malformed {
host: "https://relay.example".to_string(),
reason: "expected value".to_string(),
})
})
.await
.unwrap_err();
assert!(matches!(err, RelayError::Malformed { .. }));
}
#[test]
fn retry_after_reads_delta_seconds_only() {
let mut h = HeaderMap::new();
assert_eq!(retry_after_secs(&h), None);
h.insert(RETRY_AFTER, HeaderValue::from_static("120"));
assert_eq!(retry_after_secs(&h), Some(120));
h.insert(RETRY_AFTER, HeaderValue::from_static(" 120 "));
assert_eq!(retry_after_secs(&h), Some(120));
h.insert(
RETRY_AFTER,
HeaderValue::from_static("Wed, 21 Oct 2026 07:28:00 GMT"),
);
assert_eq!(retry_after_secs(&h), None);
}
#[test]
fn empty_host_list_is_ok_but_disabled() {
let c = RelayClient::new(reqwest::Client::builder().build().unwrap(), &[]).unwrap();
assert!(!c.is_enabled());
assert!(c.hosts().is_empty());
}
#[test]
fn hosts_are_normalized_and_deduped_in_order() {
let c = RelayClient::new(
reqwest::Client::builder().build().unwrap(),
&[
"relay1.us-west.bsky.network".to_string(),
"https://relay1.us-west.bsky.network/".to_string(),
"relay1.us-east.bsky.network".to_string(),
],
)
.unwrap();
assert_eq!(
c.hosts(),
[
"https://relay1.us-west.bsky.network",
"https://relay1.us-east.bsky.network"
]
);
assert!(c.is_enabled());
}
#[test]
fn one_bad_host_fails_loud() {
let err = RelayClient::new(
reqwest::Client::builder().build().unwrap(),
&[
"relay1.us-west.bsky.network".to_string(),
"wss://relay1.us-east.bsky.network".to_string(),
],
)
.unwrap_err();
assert!(matches!(err, RelayError::BadHost { .. }), "{err}");
}
#[test]
fn report_surfaces_the_max_and_flags_disagreement() {
let report = AdoptionReport {
collection: crate::lexicon::nsid::SUBSCRIPTION.to_string(),
observations: vec![obs("https://west", 2), obs("https://east", 40)],
failures: Vec::new(),
};
assert!(report.succeeded());
assert_eq!(report.best().unwrap().repos, 40);
assert!(report.disagrees());
}
#[test]
fn report_ties_resolve_to_the_first_configured_host() {
let report = AdoptionReport {
collection: "c".to_string(),
observations: vec![obs("https://west", 7), obs("https://east", 7)],
failures: Vec::new(),
};
assert_eq!(report.best().unwrap().source, "https://west");
assert!(!report.disagrees());
}
#[test]
fn report_with_only_failures_did_not_succeed() {
let report = AdoptionReport {
collection: "c".to_string(),
observations: Vec::new(),
failures: vec![RelayFailure {
host: "https://west".to_string(),
reason: "boom".to_string(),
}],
};
assert!(!report.succeeded());
assert!(report.best().is_none());
assert!(!report.disagrees());
}
#[tokio::test]
async fn internal_relay_host_is_refused_by_the_guard() {
let c = RelayClient::new(
reqwest::Client::builder().build().unwrap(),
&["http://169.254.169.254".to_string()],
)
.unwrap();
let report = c
.count_repos_with_collection(crate::lexicon::nsid::SUBSCRIPTION)
.await;
assert!(!report.succeeded());
assert_eq!(report.failures.len(), 1);
let reason = &report.failures[0].reason;
assert!(
reason.contains("forbidden") || reason.contains("internal"),
"expected an SSRF refusal, got: {reason}"
);
}
}