use std::num::NonZeroU32;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use crate::datasets::http::{DatasetClient, DatasetClientConfig, HttpError};
use crate::datasets::sec::catalog::RATE_LIMIT_PER_SEC;
use crate::datasets::sec::error::{Result, SecError};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
const BASE_BACKOFF_MS: u64 = 1000;
pub use crate::datasets::http::FetchMode;
#[derive(Clone)]
pub struct SecClient {
inner: DatasetClient,
user_agent: Arc<str>,
retry_count: usize,
}
impl SecClient {
pub fn new(user_agent: &str) -> Result<Self> {
Self::with_options(user_agent, RATE_LIMIT_PER_SEC, 3)
}
pub fn with_options(user_agent: &str, rate_per_sec: u32, retry_count: usize) -> Result<Self> {
let ua = user_agent.trim();
if ua.is_empty() {
return Err(SecError::MissingUserAgent);
}
if !ua.contains('@') {
eprintln!(
"kglite-sec: user_agent='{ua}' has no '@' — SEC may 403. \
Use 'Name email@domain' format."
);
}
let rate = NonZeroU32::new(rate_per_sec)
.ok_or_else(|| SecError::Decode("rate_per_sec must be > 0".into()))?;
let inner = DatasetClient::new(DatasetClientConfig {
user_agent: ua.to_string(),
connect_timeout: CONNECT_TIMEOUT,
overall_timeout: Some(REQUEST_TIMEOUT),
rate_per_sec: Some(rate),
retry_count,
base_backoff_ms: BASE_BACKOFF_MS,
});
Ok(SecClient {
inner,
user_agent: ua.into(),
retry_count,
})
}
pub fn user_agent(&self) -> &str {
&self.user_agent
}
pub fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>> {
self.inner.fetch_bytes(url).map_err(|e| self.map_http(e))
}
pub fn fetch_to_file(&self, url: &str, path: &Path, mode: FetchMode) -> Result<bool> {
self.inner
.fetch_to_file(url, path, mode)
.map_err(|e| self.map_http(e))
}
fn map_http(&self, e: HttpError) -> SecError {
match e {
HttpError::Status { code, .. } if code == 429 || (500..=599).contains(&code) => {
SecError::RateLimited {
retries: self.retry_count,
}
}
HttpError::Status { code, url } => SecError::BadStatus { status: code, url },
HttpError::Transport(msg) => SecError::Http(msg),
HttpError::Io(io) => SecError::Io(io),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_user_agent_rejected() {
let r = SecClient::new("");
assert!(matches!(r, Err(SecError::MissingUserAgent)));
let r = SecClient::new(" ");
assert!(matches!(r, Err(SecError::MissingUserAgent)));
}
#[test]
fn valid_user_agent_constructs() {
let c = SecClient::new("KGLite Test test@example.com").unwrap();
assert_eq!(c.user_agent(), "KGLite Test test@example.com");
}
#[test]
fn zero_rate_rejected() {
let r = SecClient::with_options("a@b", 0, 3);
assert!(r.is_err());
}
#[test]
fn client_is_clone_and_send() {
fn assert_send<T: Send + Sync + Clone>() {}
assert_send::<SecClient>();
}
}