use std::{sync::OnceLock, time::Duration};
use http::header::{HeaderMap, CONTENT_LENGTH, CONTENT_TYPE};
use url::Url;
use crate::region::{is_cloud_host, parse_max_age, Cached, RegionCache, RegionsResponse};
pub(crate) const MAX_ATTEMPTS: u32 = 3;
pub(crate) const BACKOFF_BASE: Duration = Duration::from_millis(200);
pub(crate) const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) const MIN_FAILOVER_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy)]
pub(crate) struct FailoverConfig {
pub enabled: bool,
pub force: bool,
pub backoff_base: Duration,
}
impl Default for FailoverConfig {
fn default() -> Self {
Self { enabled: true, force: false, backoff_base: BACKOFF_BASE }
}
}
impl FailoverConfig {
pub(crate) fn attempts(&self, host: Option<&str>, timeout: Duration) -> u32 {
if !(self.enabled && (self.force || host.map(is_cloud_host).unwrap_or(false))) {
return 1;
}
if timeout < MIN_FAILOVER_TIMEOUT {
return 1;
}
MAX_ATTEMPTS
}
}
fn to_http_url(url: &str) -> String {
if let Some(rest) = url.strip_prefix("ws") {
format!("http{rest}")
} else {
url.to_owned()
}
}
pub(crate) fn host_key(url: &Url) -> String {
format!("{}:{}", url.host_str().unwrap_or(""), url.port_or_known_default().unwrap_or(0))
}
pub(crate) fn pick_next(region_urls: &[String], attempted: &[String]) -> Option<Url> {
for raw in region_urls {
let Ok(url) = Url::parse(raw) else { continue };
if url.host_str().is_none() {
continue;
}
if !attempted.iter().any(|a| a == &host_key(&url)) {
return Some(url);
}
}
None
}
pub(crate) async fn backoff_sleep(d: Duration) {
if d.is_zero() {
return;
}
livekit_runtime::sleep(d).await;
}
const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2);
fn region_cache() -> &'static RegionCache {
static CACHE: OnceLock<RegionCache> = OnceLock::new();
CACHE.get_or_init(|| RegionCache::new(RegionCache::DEFAULT_TTL))
}
pub(crate) async fn region_urls(base: &Url, headers: &HeaderMap) -> Vec<String> {
let key = host_key(base);
let cache = region_cache();
let stale = match cache.get(&key) {
Cached::Fresh(urls) => return urls,
Cached::Stale(urls) => Some(urls),
Cached::Miss => None,
};
match fetch(base, headers).await {
Ok((urls, max_age)) => {
if max_age != Some(Duration::ZERO) {
cache.insert(key, urls.clone(), max_age);
}
urls
}
Err(()) => stale.unwrap_or_default(),
}
}
fn forward_headers(headers: &HeaderMap) -> HeaderMap {
let mut out = headers.clone();
out.remove(CONTENT_TYPE);
out.remove(CONTENT_LENGTH);
out
}
fn normalize(list: RegionsResponse) -> Vec<String> {
list.regions.into_iter().filter(|r| !r.url.is_empty()).map(|r| to_http_url(&r.url)).collect()
}
fn max_age_from_cache_control(value: Option<&str>) -> Option<Duration> {
value.and_then(parse_max_age)
}
#[cfg(feature = "services-tokio")]
async fn fetch(base: &Url, headers: &HeaderMap) -> Result<(Vec<String>, Option<Duration>), ()> {
let mut url = base.clone();
url.set_path("/settings/regions");
let resp = reqwest::Client::new()
.get(url)
.headers(forward_headers(headers))
.timeout(DISCOVERY_TIMEOUT)
.send()
.await
.map_err(|_| ())?;
if !resp.status().is_success() {
return Err(());
}
let max_age = max_age_from_cache_control(
resp.headers().get("cache-control").and_then(|v| v.to_str().ok()),
);
let list: RegionsResponse = resp.json().await.map_err(|_| ())?;
Ok((normalize(list), max_age))
}
#[cfg(all(feature = "services-async", not(feature = "services-tokio")))]
async fn fetch(base: &Url, headers: &HeaderMap) -> Result<(Vec<String>, Option<Duration>), ()> {
use isahc::config::Configurable;
use isahc::AsyncReadResponseExt;
let mut url = base.clone();
url.set_path("/settings/regions");
let mut builder = isahc::Request::get(url.as_str()).timeout(DISCOVERY_TIMEOUT);
for (name, value) in forward_headers(headers).iter() {
builder = builder.header(name.as_str(), value.as_bytes());
}
let request = builder.body(()).map_err(|_| ())?;
let mut resp = isahc::send_async(request).await.map_err(|_| ())?;
if !resp.status().is_success() {
return Err(());
}
let max_age = max_age_from_cache_control(
resp.headers().get("cache-control").and_then(|v| v.to_str().ok()),
);
let list: RegionsResponse = resp.json().await.map_err(|_| ())?;
Ok((normalize(list), max_age))
}