use std::collections::HashMap;
use std::time::{Duration, Instant};
use serde::Serialize;
use serde::ser::SerializeStruct;
#[derive(Debug, Default, Serialize)]
pub struct HostStatsMap(HashMap<String, HostStats>);
impl HostStatsMap {
#[must_use]
pub fn sorted(&self) -> Vec<(String, HostStats)> {
let mut sorted_hosts: Vec<_> = self.0.clone().into_iter().collect();
sorted_hosts.sort_by_key(|(_, stats)| std::cmp::Reverse(stats.total_requests));
sorted_hosts
}
}
impl From<HashMap<String, HostStats>> for HostStatsMap {
fn from(value: HashMap<String, HostStats>) -> Self {
Self(value)
}
}
#[derive(Debug, Clone, Default)]
pub struct HostStats {
pub total_requests: u64,
pub successful_requests: u64,
pub rate_limited: u64,
pub server_errors: u64,
pub client_errors: u64,
pub last_success: Option<Instant>,
pub last_rate_limit: Option<Instant>,
pub request_times: Vec<Duration>,
pub status_codes: HashMap<u16, u64>,
pub cache_hits: u64,
pub cache_misses: u64,
}
impl HostStats {
pub fn record_response(&mut self, status_code: u16, request_time: Duration) {
self.total_requests += 1;
*self.status_codes.entry(status_code).or_insert(0) += 1;
match status_code {
200..=299 => {
self.successful_requests += 1;
self.last_success = Some(Instant::now());
}
429 => {
self.rate_limited += 1;
self.last_rate_limit = Some(Instant::now());
}
400..=499 => {
self.client_errors += 1;
}
500..=599 => {
self.server_errors += 1;
}
_ => {} }
self.request_times.push(request_time);
}
#[must_use]
pub fn median_request_time(&self) -> Option<Duration> {
if self.request_times.is_empty() {
return None;
}
let mut times = self.request_times.clone();
times.sort();
let mid = times.len() / 2;
if times.len().is_multiple_of(2) {
Some((times[mid - 1] + times[mid]) / 2)
} else {
Some(times[mid])
}
}
#[must_use]
pub fn error_rate(&self) -> f64 {
if self.total_requests == 0 {
return 0.0;
}
let errors = self.rate_limited + self.client_errors + self.server_errors;
#[allow(clippy::cast_precision_loss)]
let error_rate = errors as f64 / self.total_requests as f64;
error_rate * 100.0
}
#[must_use]
pub fn success_rate(&self) -> f64 {
if self.total_requests == 0 {
1.0 } else {
#[allow(clippy::cast_precision_loss)]
let success_rate = self.successful_requests as f64 / self.total_requests as f64;
success_rate
}
}
#[must_use]
pub fn average_request_time(&self) -> Option<Duration> {
if self.request_times.is_empty() {
return None;
}
let total: Duration = self.request_times.iter().sum();
#[allow(clippy::cast_possible_truncation)]
Some(total / (self.request_times.len() as u32))
}
#[must_use]
pub fn latest_request_time(&self) -> Option<Duration> {
self.request_times.iter().last().copied()
}
#[must_use]
pub fn is_currently_rate_limited(&self) -> bool {
if let Some(last_rate_limit) = self.last_rate_limit {
last_rate_limit.elapsed() < Duration::from_secs(60)
} else {
false
}
}
pub const fn record_cache_hit(&mut self) {
self.cache_hits += 1;
self.total_requests += 1;
self.successful_requests += 1;
}
pub const fn record_cache_miss(&mut self) {
self.cache_misses += 1;
}
#[must_use]
pub fn cache_hit_rate(&self) -> f64 {
let total_cache_requests = self.cache_hits + self.cache_misses;
if total_cache_requests == 0 {
0.0
} else {
#[allow(clippy::cast_precision_loss)]
let hit_rate = self.cache_hits as f64 / total_cache_requests as f64;
hit_rate
}
}
#[must_use]
pub fn summary(&self) -> String {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let success_pct = (self.success_rate() * 100.0) as u64;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let error_pct = self.error_rate() as u64;
let avg_time = self
.average_request_time()
.map_or_else(|| "N/A".to_string(), |d| format!("{:.0}ms", d.as_millis()));
format!(
"{} requests ({}% success, {}% errors), avg: {}",
self.total_requests, success_pct, error_pct, avg_time
)
}
}
impl Serialize for HostStats {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let median_request_time_ms = self.median_request_time().map(|d| d.as_millis());
let mut s = serializer.serialize_struct("HostStats", 11)?;
s.serialize_field("total_requests", &self.total_requests)?;
s.serialize_field("successful_requests", &self.successful_requests)?;
s.serialize_field("success_rate", &self.success_rate())?;
s.serialize_field("rate_limited", &self.rate_limited)?;
s.serialize_field("client_errors", &self.client_errors)?;
s.serialize_field("server_errors", &self.server_errors)?;
s.serialize_field("median_request_time_ms", &median_request_time_ms)?;
s.serialize_field("cache_hits", &self.cache_hits)?;
s.serialize_field("cache_misses", &self.cache_misses)?;
s.serialize_field("cache_hit_rate", &self.cache_hit_rate())?;
s.serialize_field("status_codes", &self.status_codes)?;
s.end()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn test_host_stats_success_rate() {
let mut stats = HostStats::default();
assert!((stats.success_rate() - 1.0).abs() < f64::EPSILON);
stats.record_response(200, Duration::from_millis(100));
stats.record_response(200, Duration::from_millis(120));
assert!((stats.success_rate() - 1.0).abs() < f64::EPSILON);
stats.record_response(429, Duration::from_millis(150));
assert!((stats.success_rate() - (2.0 / 3.0)).abs() < 0.001);
stats.record_response(500, Duration::from_millis(200));
assert!((stats.success_rate() - 0.5).abs() < f64::EPSILON);
}
#[test]
fn test_host_stats_tracking() {
let mut stats = HostStats::default();
assert_eq!(stats.total_requests, 0);
assert_eq!(stats.successful_requests, 0);
assert!(stats.error_rate().abs() < f64::EPSILON);
stats.record_response(200, Duration::from_millis(100));
assert_eq!(stats.total_requests, 1);
assert_eq!(stats.successful_requests, 1);
assert!(stats.error_rate().abs() < f64::EPSILON);
assert_eq!(stats.status_codes.get(&200), Some(&1));
stats.record_response(429, Duration::from_millis(200));
assert_eq!(stats.total_requests, 2);
assert_eq!(stats.rate_limited, 1);
assert!((stats.error_rate() - 50.0).abs() < f64::EPSILON);
stats.record_response(500, Duration::from_millis(150));
assert_eq!(stats.total_requests, 3);
assert_eq!(stats.server_errors, 1);
assert_eq!(
stats.median_request_time(),
Some(Duration::from_millis(150))
);
}
#[test]
fn test_summary_formatting() {
let mut stats = HostStats::default();
stats.record_response(200, Duration::from_millis(150));
stats.record_response(500, Duration::from_millis(200));
let summary = stats.summary();
assert!(summary.contains("2 requests"));
assert!(summary.contains("50% success"));
assert!(summary.contains("50% errors"));
assert!(summary.contains("175ms")); }
}