use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use query_flow::QueryError;
use url::Url;
use crate::query::TextFile;
use crate::query::error::EureQueryError;
use super::TextFileContent;
#[cfg(feature = "native")]
pub use eure_env::cache::{CacheOptions, base_cache_dir, https_cache_dir, parse_duration};
const RATE_LIMIT_INTERVAL: Duration = Duration::from_secs(10);
static LAST_REQUEST_TIME: LazyLock<Mutex<HashMap<Url, Instant>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
static HTTP_CLIENT: LazyLock<reqwest::blocking::Client> = LazyLock::new(|| {
reqwest::blocking::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.pool_idle_timeout(Duration::from_secs(30))
.user_agent(format!(
"{}@{}",
option_env!("CARGO_BIN_NAME").unwrap_or("eure"),
env!("CARGO_PKG_VERSION")
))
.build()
.expect("Failed to create HTTP client")
});
fn check_rate_limit(url: &Url, now: Instant) -> Result<(), QueryError> {
let mut last_times = LAST_REQUEST_TIME.lock().unwrap();
if let Some(last_time) = last_times.get(url)
&& now.duration_since(*last_time) < RATE_LIMIT_INTERVAL
{
return Err(EureQueryError::RateLimitExceeded(url.clone()).into());
}
last_times.insert(url.clone(), now);
Ok(())
}
pub fn fetch_url(url: &Url) -> Result<TextFileContent, QueryError> {
check_rate_limit(url, Instant::now())?;
let response = HTTP_CLIENT.get(url.as_str()).send()?;
let status = response.status();
if status.is_success() {
let text = response.text()?;
Ok(TextFileContent(text))
} else if status == reqwest::StatusCode::NOT_FOUND {
Err(EureQueryError::ContentNotFound(TextFile::from_url(url.clone())).into())
} else {
response.error_for_status()?;
unreachable!()
}
}
#[cfg(feature = "native")]
pub fn fetch_url_cached(url: &Url, opts: &CacheOptions) -> Result<TextFileContent, QueryError> {
use eure_env::cache;
match cache::fetch(url, opts) {
Ok(result) => Ok(TextFileContent(result.content)),
Err(cache::CacheError::NotFound(_)) => {
Err(EureQueryError::ContentNotFound(TextFile::from_url(url.clone())).into())
}
Err(cache::CacheError::OfflineCacheMiss(_)) => {
Err(EureQueryError::OfflineNoCache(url.clone()).into())
}
Err(e) => Err(QueryError::from(anyhow::anyhow!(e))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn reset_rate_limit() {
LAST_REQUEST_TIME.lock().unwrap().clear();
}
#[test]
fn test_rate_limit_first_request_succeeds() {
reset_rate_limit();
let url = Url::parse("https://example.com/schema.eure").unwrap();
let now = Instant::now();
assert!(check_rate_limit(&url, now).is_ok());
}
#[test]
fn test_rate_limit_second_request_within_interval_fails() {
reset_rate_limit();
let url = Url::parse("https://example.com/schema2.eure").unwrap();
let t0 = Instant::now();
let t1 = t0 + Duration::from_secs(5);
check_rate_limit(&url, t0).unwrap();
assert!(check_rate_limit(&url, t1).is_err());
}
#[test]
fn test_rate_limit_request_after_interval_succeeds() {
reset_rate_limit();
let url = Url::parse("https://example.com/schema3.eure").unwrap();
let t0 = Instant::now();
let t1 = t0 + Duration::from_secs(20);
check_rate_limit(&url, t0).unwrap();
assert!(check_rate_limit(&url, t1).is_ok());
}
#[test]
fn test_rate_limit_different_urls_independent() {
reset_rate_limit();
let url1 = Url::parse("https://example.com/a.eure").unwrap();
let url2 = Url::parse("https://example.com/b.eure").unwrap();
let now = Instant::now();
check_rate_limit(&url1, now).unwrap();
assert!(check_rate_limit(&url2, now).is_ok());
}
}